Compare commits

...
179 Commits
Author SHA1 Message Date
github-actions[bot] eab98a0415 docs(release): prepare v1.32.1 bilingual notes (#9514)
Summary:
Add the reviewed bilingual Stable v1.32.1 record for natural turn completion and target the shared change to both Desktop and CLI.

Verification:
Release-note validation, rendering, catalog tests, documentation impact checks, and the applicable CI matrix passed.
2026-08-28 17:37:38 +08:00
SivanCola a5d349b703 Remove model-visible finish turn protocol (#9508)
Problem: ordinary successful turns required a synthetic finish tool call after the visible answer, exposing internal host bookkeeping and surfacing repair errors to users.

Root cause: the interactive tool registry and run loop treated finish as a mandatory structured boundary even though the host already owns natural response completion and durable turn status.

Fix: remove finish from provider schemas and enforcement, preserve legacy ledger/history readability, and add regressions for natural completion and legacy paired finish history.

Verification: go test ./... -count=1; cd desktop && go test ./... -count=1; go vet ./...; go run ./tools/repolint; go test -race ./internal/agent ./internal/control ./internal/boot -count=1; git diff --check.
2026-08-28 17:24:12 +08:00
SivanCola e6218fc1dd docs(release): include v1.32.0 archive reliability fix (#9504) 2026-08-28 13:36:15 +08:00
SivanCola 6d69ddb3a1 fix(desktop): retry transient session removal contention (#9502)
Reuse the bounded lease contention retry when destructive operations acquire removal guards, avoiding false cross-window busy errors while preserving fail-fast behavior for persistent holders.
2026-08-28 13:27:00 +08:00
SivanCola 88a51a607b docs(release): include final v1.32.0 MCP change (#9501)
Add the compact MCP capability discovery change merged after the release notes PR and pin guide links to the updated main-v2 documentation.
2026-08-28 12:21:22 +08:00
SivanCola d7d0f2084b fix(mcp): keep capability list compact (#9499)
Problem:
use_capability(action=list) expanded every cached MCP tool, including entries from disabled servers, and could consume tens of kilobytes of model context.

Root cause:
The model-facing list serialized the complete internal capability catalog even though MCP servers already had compact summaries and inspect supports on-demand directories.

Fix:
Keep the full catalog for routing and known-ID calls, but omit MCP server and tool entries from the global capability list. Preserve compact server status summaries, on-demand inspect behavior, the fixed provider-visible schema, and document the contract in English and Chinese.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- go vet ./...
- go run ./tools/repolint
- git diff --check
2026-08-28 12:04:36 +08:00
github-actions[bot]andSivanCola 6235799db1 docs(release): 准备 v1.32.0 中英更新日志 (#9500)
* docs(release): prepare v1.32.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.

* docs(release): correct v1.32.0 release boundary

Limit the notes to changes merged after desktop-v1.31.4, remove unmerged and historical references, and document the topic metadata migration and downgrade boundary.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: SivanCola <[email protected]>
2026-08-28 12:03:59 +08:00
SivanCola 28e6303e48 test: make release gates portable on Windows (#9494)
* test: make release gates portable on Windows

Problem:
The main-v2 release candidate failed Windows CI because path JSON was matched as raw POSIX text, Unix permission bits were asserted on Windows, and active test ledgers were left open during TempDir cleanup.

Root cause:
The tests encoded platform-specific assumptions and did not close persistent test-only file handles.

Fix:
Decode event frames before comparing canonical paths, retain the permission assertion only where Unix modes exist, and register ledger cleanup for active-handle tests.

Verification:
go test -p 4 -timeout=8m ./...
go test -race ./internal/serve ./internal/topicstate ./internal/turnevent
GOOS=windows GOARCH=amd64 go test -c for all three affected packages
go run ./tools/repolint

* test: stabilize native scrollbar replay

Problem:
The Linux transcript browser gate could misclassify a held scrollbar-track auto-repeat as a real thumb drag, then fail when later pointer movement did not reach the physical bottom.

Root cause:
Thumb discovery checked only an absolute scroll distance after one delayed move, and asynchronous DOM probes interrupted the proven browser-owned pointer transaction before it was completed.

Fix:
Require two consecutive pointer increments to produce native progress and finish the same physical drag before mutating or inspecting row geometry.

Verification:
pnpm test:transcript-browser
pnpm test:motion
pnpm test:transcript
pnpm build
go run ./tools/repolint

* test: target transcript wheel input deterministically

Problem:
The Linux transcript browser gate could send its hydration wheel event to nested content at the viewport center, leaving the transcript unchanged until the fixture timed out.

Root cause:
The physical input probe did not verify that its pointer target belonged to the outer transcript reader surface.

Fix:
Reuse the visible-row padding hit-test before sending the hydration wheel while retaining the manual-mode and physical-displacement assertions.

Verification:
pnpm test:transcript-browser
node --check bench/transcript-scroll-stability.mjs
go run ./tools/repolint

* test: harden native release fixture activation

Problem:
The Linux native-scrollbar replay could still accept a short track auto-repeat as thumb motion, while the WebView2 selection fixture could activate its topic before the final virtual row was mounted.

Root cause:
The headed Chromium probe lacked a sustained third movement check, and the native WebView2 contract assumed one early synthetic click plus the active class guaranteed a settled tail viewport.

Fix:
Require sustained native progress across a third pointer segment, retry the delayed topic click until active, and hold fixture setup at the physical bottom until the final selection target mounts.

Verification:
node --check for both browser contracts
injected WebView2 contract replay in Chromium at 1200x800
pnpm --dir desktop/frontend test:motion
cd desktop && go test ./...
go run ./tools/repolint

* test: assert cleared OAuth invariant
2026-08-28 11:31:11 +08:00
SivanCola 7775342453 fix(desktop): stabilize transcript surface ownership / 稳定对话滚动表面所有权 (#9493)
* fix(desktop): make transcript surface commits atomic

Problem:\nUnloaded question jumps could expose intermediate history windows, while stale reader or native-scroll ownership could write after tail or surface replacement.\n\nRoot cause:\nQuestion paging, paint readiness, and imperative scrolling lacked one token/generation-fenced transaction and a single enforced writer gateway.\n\nFix:\nMask question-jump history until mounted geometry is stable, route every transcript write through the generation-aware gateway, and make reader/native-thumb ownership replacement explicit and cancelable.\n\nVerification:\npnpm test:transcript; pnpm test; pnpm build; three consecutive pnpm test:transcript-browser runs; go test ./...; go run ./tools/repolint; single-scroll-writer check; git diff --check.

* fix(desktop): retain question jump ownership through paint

Problem: the opaque question-jump mask retained pixels, but the scroll arbiter did not own the full paging and paint interval. A stale reader correction or native scrollend could therefore write or release programmatic ownership before the target paint terminal.

Root cause: question navigation acquired reducer ownership only when the final indexed jump was dispatched, while the preceding masked history mutations and queued reader work remained on the previous generation.

Fix: add a token-fenced question-jump ownership controller that invalidates old async writers before loading, blocks generic scrollend release, and resolves only the matching paint terminal. Add the deterministic reader/tail race coverage and keep bundle ratchets to 0.39% gzip / 0.28% raw over main-v2.

Verification: pnpm test:transcript; pnpm test (229 suites); pnpm test:transcript-browser x3; pnpm build; go test ./...; repolint; single-scroll-writer; git diff --check.

* test(desktop): scan native thumb hitbox precisely

* test(desktop): classify unavailable native thumb input

* fix(desktop): queue question jumps behind history loads

* test(desktop): preserve verified native thumb transaction

* test(desktop): sample delivered native thumb progress
2026-08-28 09:22:03 +08:00
XTLineandSivanCola ea28602b23 feat(desktop): complete remote workspace session revival and cold-start UX / 完成远程工作区会话恢复与冷启动体验 [3/3] (#9319)
* feat(desktop-fe): add remote tab bindings and browser mocks / 增加远程项目 Tab 的前端绑定与 mock

* feat(desktop-fe): offer remote connection in the add-project menus / 加项目菜单增加远程连接入口

* feat(desktop-fe): add three-step remote connection wizard / 增加远程连接三步向导

* feat(config): pin remote projects in user config / 用户配置支持固定远程项目

[[remote.projects]] entries (host_id + workspace + title) reference
[[remote.hosts]] by name. RemoteProject/UpsertRemoteProject/
RemoveRemoteProject with Clone copy and user-scope TOML rendering;
round-trip covered in remote_config_test.go.

* feat(desktop): remote project registry and remote tab bootstrap / 远程项目注册与远程标签引导

- RemoteTabRef discriminator on TabMeta and ProjectNode.
- List/Add/RemoveRemoteProjects persist through [remote].projects;
  AddRemoteProject dedupes on (hostID, workspace).
- remoteProjectNodes appends remote project groups to the tree
  snapshot; config read failures degrade to no remote nodes instead
  of breaking the tree.
- OpenRemoteProjectTab creates-or-focuses an in-app remote tab and
  returns meta immediately; SSH + Serve bootstrap runs in the
  background and reports through remote-tab:{id}:state events.

* feat(desktop-fe): remote project groups in the project tree / 项目树渲染远程项目分组

ProjectNode.remote drives the cloud folder icon; clicking an empty
remote group row re-enters the project via OpenRemoteProjectTab
(in-flight double clicks guarded by a ref Set, settled re-opens
deduped by the backend focus path). Browser mock mirrors the Go
snapshot so the tree shows pinned remote projects in dev.

* fix(desktop-fe): wizard suggest-list z-index token; failed-connect test / 向导建议列表 z 层级 token 化并补连接失败测试

.remote-wizard__suggest-list joins the z-scale tokens instead of a
hardcoded z-index: 2. The wizard test now drives a real
stopped+error → retry → connected sequence so the connecting step's
failure path stays observable instead of always succeeding.

* fix(desktop-fe): wizard all-or-nothing finish, connect log target, dead i18n keys / 向导完成回退、连接日志目标与清理死键

- finish(): a failed OpenRemoteProjectTab rolls back the just-registered project (RemoveRemoteProject); if rollback fails, refresh keeps the tree honest. AddRemoteProject failures return before any open attempt.
- connect(): target host comes from the caller, fixing the stale-closure log that printed a raw host id on first connect.
- Drop unused remoteWizard.folderName/createFolder keys from en/zh/zh-TW; wizard header comment no longer claims mkdir.

* feat(desktop): remote tab serve bridge — auth handshake, session entry, SSE pump / 远程标签 Serve 桥:认证握手、进入会话与事件泵

- serveHandshake: one POST /auth/token trades the pre-shared token for the
  HttpOnly session cookie (same flow the web shell uses); the token never
  enters a request line or the serve access log.
- enterRemoteSession: POST /new for fresh sessions; SessionName opens
  resolve the name against GET /sessions and POST /resume with the entry
  PATH (serve takes path, not name).
- remoteTabPump: subscribes GET /events and forwards each data frame
  verbatim as remote-tab:{id}:event; generation guard lets a superseded
  pump self-exit once reconnect logic lands.
- bootstrapRemoteTab now attaches the bridge after EnsureServer: handshake
  → pump subscribed → session entered → state=ready, bounded by a 30s
  sub-context.
- remoteEventHook test seam; 4 httptest bridge tests incl. pump cleanup
  (uncancelled /events connections otherwise hang httptest Close).
- Drop plan-task references from code comments (app.go, remote_projects.go,
  types.ts).

* feat(serve,desktop): model/effort over HTTP; full remote command proxy / Serve 开放模型与努力度路由,桥补全命令代理

- serve: POST /model and POST /effort expose the existing switchModel /
  switchEffort internals to HTTP clients (desktop remote tab + web shell
  pickers). Route validation tests; switch semantics stay covered by the
  existing model-switch suites.
- bridge: SetRemoteTabModel/Effort/PlanMode, Compact/Fork/Summarize/
  ForgetRemoteTab, RemoteTabBranches/Skills — one forward each through the
  shared remoteTabPost/remoteTabGet helpers. auto-approve-tools and bypass
  are deliberately skipped: they are legacy aliases of
  tool-approval-mode=yolo.
- bridge.ts: matching AppBindings declarations + mock stubs; fakeServe
  gains the new endpoints and the forwarding table gains 8 rows.

* feat(desktop): remote tab reconnect lifecycle and close / 远程标签断线自愈与关闭

- CloseRemoteTab: idempotent teardown — pump stops, registry entry drops,
  serve + SSH stay for other tabs.
- onStatus now drives open tabs through SSH transitions: connecting/
  reconnecting suspends pumps (gen bumped so stale pumps exit), connected
  re-attaches each tab to the still-running remote serve (fresh handshake
  + pump, no session re-entry needed — the serve holds the session),
  stopped parks tabs in error with the host failure text.
- Unexpected stream death now flags reconnecting and attempts one
  re-attach instead of reporting a hard error; commands on tabs that are
  reconnecting or failed fail fast with a clear message.
- Tests: close idempotence, host drop → reconnect → ready with a fresh
  event stream, terminal failure text propagation.

* feat(desktop): remote tabs join the strip with a native surface / 远程标签进入标签栏并渲染原生表面

- ListTabs merges open remote tabs; a highlighted remote tab deactivates
  local entries so exactly one strip item is active.
- SetActiveTab routes by registry membership: remote ids take the strip
  highlight, local ids clear it and run the existing path. CloseTabWithPolicy
  tears a remote tab down without touching the local runtime.
- OpenRemoteProjectTab emits remote-tab:opened with the meta on open and
  re-focus; the frontend adopts it (seedActiveTabMeta + switchTab) and
  renders RemoteSessionSurface instead of the local transcript when
  activeTab.remote is set.
- useRemoteSession: state/event subscriptions, snapshot hydration with
  retry (covers the mount-vs-event race), frame collection, and typed
  submit/cancel/approve/answer wrappers. Surface renders the four
  connection states; hydrated content wins over a still-pending state
  event. Styles use design tokens; locales in en/zh/zh-TW.
- Tests: ListTabs/activation/close integration (Go), surface + hook
  contract (tsx).

* feat(desktop-fe): remote transcript reuses the local pipeline end to end / 远程会话端到端复用本地渲染管线

- useRemoteSession feeds serve frames (same wire form as agent events)
  through the exported local reducer, hydrates /history through the local
  history action, and injects the optimistic user bubble with the local
  user action — items, live streaming, approvals, and asks therefore shape
  exactly like a local tab.
- RemoteSessionSurface renders the shared Transcript component; only the
  connection state machine and the approval/ask cards stay remote-specific.
  The composer stays the shared local one with send/cancel/running fed by
  the session (App-level wiring). Bespoke row/composer rendering and its
  styles/strings are gone; the mock snapshot returns an empty history like
  a fresh local session and the mock turn streams through the reducer.
- State type exported from useController; the svg asset stub also covers
  .css so tsx tests can mount the transcript chain.

* feat(desktop-fe): remote sessions in the project tree / 项目树远程会话行与远程菜单

- Remote groups list their serve sessions (RemoteProjectSessions) while
  the host is connected; rows open by sessionName through the ensure-open
  path. A new session invalidates the cached listing.
- Group row gains a connection badge driven by the live host status.
- Remote groups swap the local project menu for remote actions: new remote
  session, open remote window, stop remote server, unpin (removes the
  registration and refreshes). The + action opens a remote session instead
  of creating a local topic — local reveal/rename/remove no longer fire on
  remote rows.
- Source-contract test pins the tree wiring and the shared-composer prop
  branching in App.

* feat(desktop): session titles, new-session reset, and demo preseed / 会话标题、新建会话重置与演示预置

- The SSE pump schedules a title refresh after turn_done: the serve's
  LLM-generated title for the current session replaces the workspace-name
  default and rides the tab-opened channel, which the chrome merges into
  the existing strip entry (no re-switch when the tab is already active).
- A NewSession open on a live tab now POSTs /new: the previous session
  stays in the remote history list, the tab switches to the new empty one
  (title resets), and the re-emitted ready state forces the frontend to
  re-sync its snapshot — which now happens on every ready transition, not
  just the first hydration.
- The tree hydrates host statuses once on mount, so badges and the
  session-fetch gate are correct without a wizard run.
- Browser-dev mock preseeds a connected demo project, an open demo tab,
  and a demo session row; the canned turn adopts an AI-style title and a
  new-session open resets the demo tab like the real backend.
- Tests: title adoption + new-session reset (Go), ready re-sync (surface),
  preseed contract.

* feat(desktop): authoritative remote session lifecycle / 远程会话生命周期权威化

- The session listing is backend-authoritative: RemoteProjectSessions
  merges a desktop-view blank row (name empty, localized 新的会话 title)
  while the tab holds a fresh session the serve has not written yet, so
  the tree renders one source of truth.
- Blank reuse mirrors EnsureBlankTab: a sessionReset flag on the tab makes
  further new-session opens re-activate instead of POSTing /new again; the
  flag clears when the session earns a serve title or a resume lands.
- resumeRemoteTabSession switches a live tab to a listed session (POST
  /resume, title adoption, ready re-emit); the bootstrap gives fresh
  sessions the localized default title.
- serve /sessions entries carry mtimeMilli; the listing orders pinned
  first and merges desktop-owned title overrides; SetRemoteSessionPinned
  and SetRemoteProjectTitle back the tree actions.
- fakeServe mirrors serve semantics (/new abandons current, /resume
  restores it); tests pin the blank-reuse contract and resume flow.

* feat(desktop-fe): remote sessions ride the local renderer with full parity / 远程会话全面复用本地管线

- Session rows join the TREE DATA as synthetic topic nodes, so filtering,
  workbench arrangement, and the pinned section treat them exactly like
  local topics; clicks, pin, rename, and archive route to the remote
  bindings and the blank row comes from the authoritative listing (the
  frontend blank state machine is deleted).
- Project menus mirror the local templates item-for-item (new session,
  rename project via registry title, reveal in remote file manager, copy
  path, archive active conversation, unpin) in classic and workbench
  variants; AI rename stays disabled for remote rows.
- The shared Composer serves remote tabs: send/cancel/running from the
  session hook, model switch through SetRemoteTabModel with the capsule
  label from the serve status; the topic-bar pencil opens rename.
- The mock mirrors the whole chain: demo preseed (project, tab, session
  row with an 18-turn history), dynamic session store, local-script
  canned turns with cancellation, immediate 202-style submit resolve,
  session-interrupting switches, and model catalog switching.

* test(remote): multi-workspace serve coexistence coverage / 多工作区 serve 共存测试覆盖

- One fake host, two workspaces: slug-keyed state/token files coexist,
  re-ensure reuses only its own serve, Stop(A) leaves B alive, and each
  launch script carries only its own concrete state paths (plan task 1.1
  coverage over the as-built layout).

* feat(desktop): wizard-level remote platform check / 连接向导阶段的远程平台检查

- CheckRemotePlatform runs uname through the same ParseUname gate
  EnsureServe applies; the connect wizard awaits it at the connecting
  step, so an unsupported remote OS (V1: Linux/macOS) fails with one
  clear message before directory browsing starts, instead of deep in
  serve bootstrap after the project is registered.

* refactor(desktop): per-workspace remote serve registry and stop/status/logs API / 按工作区键控的远程 serve 注册表与停止状态日志接口

- managedHost tracks one serveEntry per workspace instead of a single
  server+token; the loopback tunnel is named serve-<slug> from
  store.RemoteWorkspaceSlug so one host holds one independent forward
  per workspace.
- StopServer/ServerStatus/ServerLogs gain a workspace parameter; the
  bound StopRemoteServer/RemoteServerStatus/RemoteServerLogs follow,
  with every frontend call site updated in the same commit because
  remote:server events now interleave per workspace.
- The web window records its workspace so reconnect refresh re-ensures
  it and stop closes it only on a match; the frontend store nests
  servers[hostId][workspace] and the panel server tab manages the
  input's workspace (the host's first entry while the input is empty)
  (plan task 2.1).

* test(desktop): assert StopRemoteServer forwards the workspace to the kernel / 断言 StopRemoteServer 向内核转发工作区参数

- Wires the fake kernel's stoppedWorkspaces tape to a real assertion in
  the stop-closes-window test, so the bound method's workspace
  parameter is verified alongside the window behavior.

* fix(desktop-fe): type-safe remote test mocks / 远程测试桩类型修复

- The optimistic-bubble assertion wraps its optional-chained some() in
  Boolean so ok() receives a boolean; the panel mock's window.go App
  object casts through Partial<AppBindings> like the wizard mock, and
  the wizard's Promise.withResolvers timers become plain Promise
  constructors (lib-target independent). These are what editors report
  when they type-check the tsconfig-excluded test files.
- The default React imports stay: tsx compiles these excluded files
  with the classic JSX transform, so the seemingly unused import is
  required at runtime.

* feat(desktop): remote tabs persist and restore as disconnected shells / 远程标签持久化与断连空壳恢复

- desktop-tabs.json gains remoteTabs + remoteTabOrder (omitempty: no
  remote tabs open ⇒ byte-identical legacy file). Entries carry only
  id/host/workspace/title — no session: clicking a restored shell
  reconnects and lands in a fresh blank session, and the tree group's
  session rows remain the picker for past conversations.
- restoreOrBuildTabs rebuilds shells in a disconnected state (no
  connect, no serve) and routes a persisted remote active id to the
  strip highlight; SetActiveTab on a shell and the tree-group
  ensure-open both bootstrap the reconnect in place, reusing the id.
- New disconnected state rides TabMeta.remoteState so the surface
  renders before any event this run; the surface offers a reconnect
  button; host status transitions never flip a shell into a runtime
  state.
- ReorderTabs accepts the full mixed strip: local and remote
  subsequences validate and persist separately (drag-reorder with
  remote tabs used to fail server-side). Single-surface layouts
  collapse remote shells to the active one like local tabs (plan
  task 3.1 remainder: Guards/Restore/Round-trip).
- The surface test's OpenRemoteProjectTab mock returns a TabMeta to
  match the binding signature (the void return broke the
  Partial<AppBindings> cast under editor type-checks).

* fix(remote): generous serve/upload budget and pipelined SFTP writes / 放宽 serve 上传时限并流水线化 SFTP 写入

- The CLI serve/fs paths had a flat 60s deadline; a same-platform
  binary upload is ~55MB and real links sit under 1MB/s, so the
  deadline failed exactly the install mode offline hosts need. Both
  budgets rise to 10 minutes.
- sftpfs copies uploads through a 1MB buffer so each Write keeps ~32
  SFTP packets in flight; with the default 32KB io.Copy buffer every
  Write is one packet and concurrent writes degenerate to a packet
  per round-trip. (A pkg/sftp throughput gap vs OpenSSH sftp remains
  on high-RTT links: measured 25-40KB/s vs 588KB/s; tracked
  separately.)

- List resolves a leading "~" server-side (Stat-verified, falling back
to the session start directory): the SFTP protocol does not expand it,
so the wizard's fresh-host browse of "~" failed with
"file does not exist".

* feat(remote): local-proxy credential mode kernel / 本地代理凭证模式内核层

- RemoteHostEntry gains credential_mode (remote | local-proxy) with
  validation, renderer, and a --credential-mode flag on remote add.
- bootstrap.CredentialProxyOptions: EnsureServe installs a
  tunnel-backed provider entry into the remote user config
  (idempotent append; a base_url change rewrites just that line) and
  LaunchCommand injects the virtual token via the serve environment
  plus --model <provider> — the token never rides argv or files.

* feat(desktop): desktop-held credentials for remote serves / 远程 serve 桌面持钥模式

- credential_proxy.go: a loopback key holder swaps the per-host virtual
  token for the real provider key and streams responses through
  (SSE-safe). Tokens derive from a persisted secret, so a restarted
  desktop keeps working with a reused remote serve; rotating the
  secret revokes every token at once.
- EnsureServer orchestrates local-proxy hosts: proxy starts, the
  cred-proxy:<host> reverse tunnel opens (forward.Remote, idempotent),
  and the bootstrap receives the token + provider entry to install.
- Host form gains the credential-mode select (three locales); the
  locale budget gate rides along (remote copy grew the chunks past
  the old 55.8/55.0 gates).
- E2E: TestCredentialProxyRealHostSmoke (env-gated) drives connect →
  reverse tunnel → serve launch → one real model turn and asserts the
  reply arrived through desktop-held credentials; verified against a
  live host.

* fix(desktop-fe): tolerate the wails dev spinner crash / 容忍 wails dev 转圈组件崩溃

- index.html gains the #wails-spinner anchor expected by the v2.13 dev
  runtime.
- The global crash screen now ignores errors whose stack lives entirely
  inside /wails/*.js: the v2.13 dev runtime's Svelte spinner throws
  "Cannot read properties of null (reading 'nodes')" on mount while the
  app itself keeps working (verified headless: full UI renders), so the
  fatal report must not escalate third-party runtime noise.

* fix(remote): materialize the preset default_model relies on / 物化默认模型依赖的内置供应商

- Appending the credential-proxy [[providers]] entry replaces the
  remote's built-in provider defaults, so a default_model like
  "deepseek-flash" that resolved only through those defaults would
  dangle and crash the serve at startup (surfacing as a confusing
  port-file timeout). The install now materializes the builtin entry
  default_model refers to as an explicit block BEFORE ours; the
  default_model line itself is never rewritten, so every entry point
  (CLI, web window, remote-mode desktop launches) keeps resolving.

* feat(desktop-fe): credential-mode segmented control in the wizard / 向导增加凭证模式分段控件

- The wizard's inline host form gains the same remote|local-proxy choice
  as the hosts page, rendered as a provider-add-segmented control after
  the download method (established pattern; zero new styles or locale
  keys). Wizard-created hosts no longer silently default to remote mode.

* fix(remote): heal the credential provider config on reuse too / 复用 serve 时同样治愈凭证配置

- ensureCredentialProvider now runs before the reuse check, so a live
  serve's config gains the materialized default_provider entry as well —
  previously only fresh launches installed it, leaving reused serves on
  boxes with the old dangling-default config.

* fix(desktop): ephemeral reverse-tunnel port for the credential proxy / 凭证代理反向隧道改用临时端口

- The remote bind drops the fixed 18999 in favor of 127.0.0.1:0 (the
  SSH layer reports the actual port; the materialized provider entry's
  base_url already rewrites on port change). A half-dead session's
  stale listener can no longer block serve starts with a confusing
  "tcpip-forward request denied".

* feat(serve,agent): provider reload endpoint, provider_unreachable event, status reconciliation fields / serve 增加供应商重载端点、通道不可达事件与状态对账字段

* feat(remote): credential heal closes the loop — change signal and .env token delivery / 凭证治愈闭环:变更信号与 .env 令牌交付

* feat(desktop): reverse-channel self-healing, single-pump attach, busy-soft session entry / 反向通道自愈、单泵附着与忙碌软进入

* feat(desktop-fe): remote session running reconciliation, submit rollback, interruption notices / 远程会话运行对账、提交回滚与中断通知

* feat(desktop-fe): remote project tree and wizard polish / 远程项目树与向导打磨

* fix(desktop-fe): make remote-session-surface test runnable under the default runner / 修复远程会话面测试在默认跑测器下的崩溃

- 按 remote-server-panel 的既有模式把 remote-session-surface.test.tsx
  注册到 test:remote(svg-stub-register 加载):其渲染链经 Transcript
  懒加载 ScrollDiagnosticPanel,再传递导入 logo-wordmark.svg,裸 tsx
  无法解析该扩展名
- ScrollDiagnosticPanel 读 import.meta.env.DEV 改为可选链:裸 tsx 下
  import.meta.env 为 undefined(此前 __BUILD_CHANNEL__ 未定义即开启
  诊断面板,模块求值即崩);Vite 构建行为不变
- 验证:svg-stub 跑测 24/24 通过,tsc 无告警

注:remote-error-ux.test.tsx 的 5 个安全摘要断言失败为分支合并前
既有问题(d597b10fd 上同样 4 过 5 挂),与本次合并无关。

* feat(serve): multi-session detach, runtime status, and gzip snapshot transport / 多会话后台切换、runtime 状态与 gzip 快照传输

- Add detached multi-session controllers and lease split/adopt so busy
  sessions keep running across switches, with session-tagged SSE frames
- Expose GET /status?runtime=1 for desktop hydrate/reconcile without
  wallet Balance, aligning remote running checks with local RuntimeStatus
- Compress large snapshot JSON responses with gzip middleware
- Keep provider reload retiring stale detached controllers after
  credential-channel heal

Verification: go test ./internal/serve -count=1 -run 'TestStatusRuntimeQuerySkipsBalance|TestProvidersReloadRetiresStaleDetached|TestGzipMiddleware'

* feat(desktop,remote): credential-channel watchdog and provider heal / 凭证通道 watchdog 与供应商配置自愈

- Watch the reverse credential tunnel at runtime and re-heal when the
  ephemeral remote port drifts after SSH reconnects
- Rewrite the remote reasonix-desktop-proxy base_url through
  HealCredentialProvider, then reload serve providers (cancel-then-reload
  when a doomed busy turn blocks 409)
- Gate bootstrap reuse on session-events / detached-heal capabilities so
  stale serves are replaced instead of fast-reused on a dead loopback port

Verification: go test ./desktop -count=1 -run 'TestCredentialChannel|TestNeedsHeal|TestCancelThenReload' ; go test ./internal/remote/bootstrap -count=1 -run 'Credential|Heal'

* fix(desktop): remote session snapshot race, hydrate slimdown, and bridge-chain acceptance / 修复远程会话快照竞态并瘦身 hydrate

- Stop RemoteTabSnapshot concurrent map writes by copy/merging the per-tab
  ETag cache under remoteTabMu instead of sharing it across unlocked workers
- Let hydrate/reconcile fetch only /history + /status?runtime=1, coalesce
  overlapping ready/mount hydrates, and rate-limit REFUSED command logs
- Add bridge-chain acceptance probes (Open→ready→snapshot stampede) and FE
  surface assertions so verification walks the real frontend binding path

Verification: go test ./desktop -race -count=1 -run 'TestRemoteTabSnapshot|TestRemoteSessionHydrateChainFromOpenToSnapshot|TestServeGetCachedAcceptsGzip'; node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx

* feat(desktop,cli): remote session-path switch and serve capability markers / 远程会话按路径切换与 serve 能力标记

- Pass listing-row sessionPath/sessionTitle into OpenRemoteProjectTab so
  resume skips a redundant GET /sessions name lookup
- Project live turn state onto remote session rows from tagged runtime
  events, including background sessions
- Advertise --session-events / --detached-heal (and the rolling caps token)
  from serve --help so remote bootstrap can replace outdated binaries

Verification: go test ./desktop -count=1 -run 'TestResumeRemoteTabSessionKnownPathSkipsListing|TestRemoteTabPumpRoutesFramesBySession'; go test ./internal/cli -count=1 -run Serve

* fix(desktop,remote): heal dead credential reverse tunnel and arm reuse watchdog / 修复凭证反向隧道死端口与复用路径 watchdog

- Mark remote forwards Down when Accept exits so ensure no longer reuses a dead Up port
- Probe advertised reverse ports before reuse, and probe before provider reload
- Always finalizeCredentialChannel (heal + startCredentialWatchdog) on reused EnsureServer paths

Verification: go test ./desktop -count=1 -run 'TestFinalizeCredentialChannelStartsWatchdog|TestCredentialChannelDecision|TestReloadServeProvidersCancelsBusyTurn|TestCredentialProxyReverseForwardEndToEnd'; go test ./internal/remote/forward -count=1 -run 'TestRemoteForwardAcceptExitMarksDown|TestRemoteForwardEndToEnd|TestReplace'

* fix(desktop): keep remote tab active when switching from a local session / 本地切远端时不再回滚到本地会话

- Bypass local HistorySliceForTab for remote targets in switchTab
- Finish hydrate_done and SetActiveTab without bouncing back on local hydrate errors
- Add FE regression coverage for the local→remote switch path

Verification: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-switch-from-local.test.tsx

* fix(desktop): restore remote session identity without auto-opening disconnected shells / 恢复远端会话身份且首屏不自动打开断开壳

- Persist SessionName/SessionPath and resume on revive instead of always POST /new
- Leave remoteActiveTabID empty on restore so first open stays on the local surface
- Make remoteTabMeta.Active follow remoteActiveTabID and expose TopicID/SessionPath for tree matching

Verification: go test ./desktop -count=1 -run 'TestRemoteTabPersist|TestActivateDisconnected|TestListTabsRemoteActiveFollowsHighlight|TestRemoteTabMetaExposesSessionIdentity|TestRemoteTabRestoreBuildsDisconnectedShells'

* fix(desktop): drop disconnected remote placeholder and revive on activation / 去掉远端未连接占位页并在激活时直接重连

- Remove the remote-surface--disconnected restored-shell page
- Treat disconnected mounts/events as connecting and kick SetActiveTab revive/bootstrap
- Keep strip shells available without parking the content surface on the placeholder

Verification: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx

* fix(desktop): align remote session-row active highlight with local topicIsActive / 对齐远端会话行点击高亮

- Synthesize remote tree rows with root + sessionPath for topicIsActive matching
- Cover active/inactive remote session-row highlight regressions in FE tests

Verification: node --import tsx src/__tests__/project-tree-runtime.test.ts; node --import tsx src/__tests__/remote-project-tree.test.tsx

* fix(serve,desktop): strip reasoning-language from /history and ratchet bundle budget / 剥离历史中的 reasoning-language 并上调 bundle 预算

- Shape serve /history user content with agent.UserMessageText so transient
  <reasoning-language> blocks never leak into remote transcripts
- Raise the raw initial JS/CSS budget for the remote UX fix path

Verification: go test ./internal/serve -count=1 -run 'TestHistoryMessagesStripTransientReasoningLanguageBlock|TestHistoryMessagesPreserveToolDetails|TestSessionsListPreviewStripsTransientReasoningLanguageBlock'; node desktop/frontend/scripts/check-bundle-budget.mjs

* fix(desktop-fe): route new-session entries to the remote tab when active / 远程会话激活时新建会话走远程分流

* fix(desktop): swap test event hooks race-safely while pumps emit / 测试中途换事件钩子改为锁保护避免数据竞态

* fix(desktop): give blank remote sessions a highlightable identity / 空白远程会话暴露可高亮身份并在物化后迁移

* fix(desktop-fe): pin test locale to jsdom navigator / 测试固定 jsdom navigator 避免系统语言影响断言

* feat(desktop): cold-start remote serve on group click with busy feedback / 点击远程项目组即引导连接并给出黄灯与刷新反馈

* fix(desktop,remote): strip transient blocks from remote history and retire stale serves / 远程历史剥离临时块并强制升级旧远端 serve

* perf(desktop): resume remote sessions off the binding path / 远程会话切换移出绑定关键路径并给出即时行反馈

* refactor(desktop): make wizard onboarding local-proxy only / 向导接入一律本地持钥并清理凭证模式选择

* chore(desktop-fe): ratchet bundle budget for remote-fix pass / 上调初始 JS 预算适配远程修复增量

* chore(desktop-fe): ratchet raw bundle budget for remote-fix pass / 上调原始体积预算适配远程修复增量

* fix(desktop): dial the host before the ensured group listing / 确保列表冷启动前先拨通 SSH 主机

* fix(desktop-fe): ensure remote listing on expand regardless of cached serve state / 展开远程组不再信任可能陈旧的 serve 状态

* fix(desktop-fe): cold-start auto-expanded remote groups too / 自动展开的远程组同样冷启动连接

* fix(desktop-fe): land remote listing rows dropped by effect churn / 远程列表结果不再被依赖抖动丢弃

* fix(desktop-fe): pin task-monitor test locale to jsdom navigator / 任务面板测试固定语言避免系统语言影响

* fix(desktop-fe): pin test locale to jsdom navigator / 测试固定 jsdom navigator 避免系统语言影响断言

* fix(desktop-fe): add missing readOnly to local-only recovery fixtures / 补齐 local-only 恢复用例缺失的 readOnly 字段

* fix(desktop-fe): pin test locale to jsdom navigator / 测试固定 jsdom navigator 避免系统语言影响断言

* fix(desktop-fe): add missing readOnly to local-only recovery fixtures / 补齐 local-only 恢复用例缺失的 readOnly 字段

* chore(desktop-fe): ratchet raw budget for the serve-scaling stacked tip / 上调原始体积预算适配 serve 扩展层

* fix(desktop):Address the data race risks caused by the passing of
internal pointers within locks

* fix(desktop): harden lock scope and orphan writes, type serve status errors, share one handshake builder / 锁域与孤儿写入加固、serve 状态错误类型化并统一握手构造

* fix(desktop): type legacy-serve fallback and reverse-probe errors, log the async serve replacement, inject the credential route registrar / 旧 serve 淘汰与反向探活错误类型化、异步替换补日志并注入凭证路由注册依赖

* fix(desktop): move remote-tab identity adoption, meta builds, and bootstrap snapshots under the tab lock / 远程标签身份采纳、meta 构造与引导快照收进 tab 锁内

* refactor(desktop): merge the desktop model pickers and thread one config snapshot through credential registration / 合并桌面模型选择器并让凭证注册全程共用一份配置快照

* fix(desktop): cache remote prefs in memory keyed by user dir and surface prefs write failures / 远程偏好按用户目录键控内存缓存并暴露写盘失败

* perf(serve): list sessions via branch-meta previews and one stat per entry / 会话列表改走 sidecar 预览并去重 stat

* perf(remote): share one probe round in EnsureServe and one-pass capability check / EnsureServe 复用路径共享单轮探测并单遍能力检查

* feat(desktop): optimistic remote session lists, unified group badge, hydrate skeleton, and pre-ready snapshot warm / 远程会话列表乐观渲染、组灯统一、水合骨架并预热快照

* feat(remote): complete multi-session serve integration

Preserve active remote turns across session switches, route tagged events to the owning tab, and expose exact session paths to the desktop tree. Add gzip snapshot transport, capability-gated remote bootstrap, and credential-channel watchdog recovery. Update Remote SSH documentation and cover the integration with Go, race, and frontend tests.

* refactor(remote): satisfy repository structure gates

* fix(remote): close multi-session lifecycle races

* fix(remote): preserve foreground session routing

Problem:\nServe and Desktop could lose the foreground session identity when controllers detached, recovered, or rotated through slash commands. Replay and status requests could also mix controller generations, and background runtime work was underreported.\n\nRoot cause:\nController ownership callbacks were rebound to keeper-only handlers, status and replay sampled the mutable controller repeatedly, and the Desktop did not reconcile a recovered foreground path before routing tagged frames.\n\nFix:\nCarry the Serve ownership binder across keeper transfers, use single controller snapshots, refresh transition tags and broadcaster routing, expose the authoritative session path, and reconcile unknown foreground frame paths from status.\n\nVerification:\ngo run ./tools/repolint\ngo test ./...\ngo vet ./...\n(cd desktop && go test ./...)\n(cd desktop && go vet ./...)\ntargeted go test -race for control, Serve, and Desktop multi-session paths\ngit diff --check

* fix(remote): close final session routing gaps

* fix(remote): close remaining serve lifecycle races

* test(cli): close queued-paste controller

* fix(remote): close final multi-session routing gaps

* fix(remote): fence recovery lifecycle races

* fix(remote): close final multisession lifecycle gaps

* fix(remote): heal all tracked proxy workspaces

* fix(remote): harden multisession ownership handoff

* fix(remote): close remaining multisession races

* test(serve): make rotation lock probe observable

* fix(remote): close session handoff races

* test(remote): use test-scoped attach context

* fix(remote): isolate session adoption state

* fix(serve): preserve committed session routing

* test(serve): exercise real silent rotations

* fix(remote): preserve listing adoption and reload budgets

* test(remote): await emitted ready state

* fix(remote): fence session rehydration

* fix(remote): harden process and session handoff

* fix(remote): harden async switching and cached state

* fix(remote): fence cross-session ownership

* fix(remote): preserve resume lifecycle events

* fix(remote): reconcile resume ownership and routing

* fix(remote): preserve lifecycle work under load

* refactor(desktop): extract remote project registry

* fix(remote): commit session routing before replay

* test(remote): adapt prompt drain regression to async resume

* chore: trigger CI after fork workflow approval

* refactor(remote): satisfy repository structure gates

* chore: run CI on final review head

* fix(remote): address final session recovery review

* test(agent): satisfy cached preview lint

* fix(desktop): satisfy macOS remote lint

* fix(desktop): close remote session recovery gaps

* fix(desktop): fence remote identities and listing caches

* fix(desktop): order remote listings and projections

* fix(desktop): fence reconnect selections and repairs

* fix(desktop): close remote session generation races

* fix(desktop): preserve remote session authority on recovery

* fix: keep listing projections generation-consistent

* fix: close remote recovery consistency gaps

Problem:
- Passive remote listing failures could erase visible topics.
- A transcript commit interrupted before its revision ledger could leave a stale listing projection certified.
- Deferred New Session could trust a reconnect-time blank snapshot after the session gained content.

Root cause:
- Passive refreshes treated transport errors as authoritative empty results.
- Listing freshness was invalidated only after the transcript revision advanced.
- Pending selection application reused a captured reuseBlank bit instead of current tab state.

Fix:
- Preserve the last successful remote rows and expose retryable group errors.
- Invalidate certified projections before committing transcript bytes while preserving transcript-first durability for brand-new sessions with malformed metadata.
- Recheck the current blank state before applying deferred New Session intent.

Verification:
- go test ./internal/agent ./internal/control
- go test -race ./internal/agent ./internal/sessioncatalog
- cd desktop && go test ./...
- pnpm --dir desktop/frontend test:all
- pnpm --dir desktop/frontend build
- go vet ./... (root and desktop)
- go run ./tools/repolint

* fix(agent): make listing invalidation monotonic

Problem: A stale whole-sidecar metadata writer could restore a certified listing projection after a transcript save had invalidated it.

Root cause: Projection invalidation only cleared the schema marker while leaving the same persistence revision, so equal-revision metadata preservation did not retain the invalidation.

Fix: Reserve the next transcript revision before mutation, finalize the commit in that same WAL/ledger generation, and cover stale metadata writeback with a regression test.

Verification: focused crash and projection tests; go test ./internal/agent ./internal/control; go test -race ./internal/agent ./internal/sessioncatalog; desktop go test/vet; root go vet; repolint.

* fix: close remote preference and save retry races

Problem: A stale remote title refresh could migrate blank-session preferences after its tab generation changed, and a writer-bound retry could reuse a stale tail baseline after reserving a metadata revision.

Root cause: Preference migration happened before the tab fence, while the writer-tail fast path validated the event index and log size but not the metadata ledger identity.

Fix: Move preference migration into the client/generation/route transaction, and disarm writer-tail classification whenever the metadata revision or digest differs from its baseline.

Verification: focused regressions; go test ./internal/agent ./internal/control; go test -race ./internal/agent ./internal/sessioncatalog; desktop go test/vet; root go vet; repolint.

* fix: preserve remote session authority without global stalls

* test: exercise registry state in title migration probe

* test(desktop): join remote bridge cleanup boundaries

* refactor(frontend): keep remote integration within budgets

* fix: preserve retryable remote and migration state

* fix: preserve remote selection authority during retries

* fix(remote): preserve token across failed serve retirement

* fix(desktop): serialize in-flight remote selections

* fix: stage remote authority transitions safely

---------

Co-authored-by: SivanCola <[email protected]>
2026-08-27 18:23:56 +08:00
SivanCola 7ae562e3ad Fix stalled Standard todos / 修复 Standard 待办停滞 (#9483)
* fix: continue active standard todos

Problem:
Standard turns can persist an in-progress Todo and then end with only a promise to act, leaving the UI reporting active work after execution stopped.

Root cause:
Final readiness intentionally does not continue ordinary Standard turns, and the Todo shelf rendered persisted status without the owning tab runtime state.

Fix:
Add a trusted, same-Run Standard Todo continuation guard bounded to two tail prompts and gated by novel host receipts and user-work preemption. Render Todo activity from runtime state and route explicit Continue actions to the exact tab.

Verification:
- go test ./...
- (cd desktop && go test ./...)
- focused go test -race for Standard Todo continuation
- (cd desktop/frontend && pnpm build && pnpm test:typecheck && pnpm test:todo-visibility)
- real-browser Todo preview and Continue click flow

* chore: ratchet App repolint budget

Problem:
The Todo continuation owner-layer guard adds 19 lines to App.tsx, exceeding its existing file-size debt budget.

Root cause:
The repolint baseline remained at the pre-change App.tsx line count.

Fix:
Raise only the App.tsx file-size baseline from 4728 to 4747 and leave every unrelated debt baseline unchanged.

Verification:
- go run ./tools/repolint
- git diff --check
2026-08-27 17:33:14 +08:00
SivanCola 5e7a1c5d03 fix(desktop): move topic metadata to SQLite / 将 Topic 元数据迁移到 SQLite (#9466)
* fix(desktop): move topic metadata to SQLite

Problem: Desktop stored generated topic metadata across four workspace-local JSON files, polluting projects and allowing partial multi-file writes.

Root cause: Topic metadata had no authoritative transactional store or cross-version compatibility bridge.

Fix: Add per-scope SQLite stores, lazy migration and legacy mirroring for existing scopes, tombstone-aware reconciliation, corruption recovery, and future-schema protection.

Verification: root and Desktop test suites, focused and full race tests, golangci-lint, repository lint, and diff checks passed.

* fix(desktop): harden topic state reconciliation

Problem:
Desktop topic migration could overwrite concurrent manual titles, prune explicit restores in bridged scopes, and lose state across split writes or damaged databases.

Root cause:
Background repair replaced stale whole indexes, tombstone ordering was not restore-aware, and title fields were committed independently without session-backed corruption recovery.

Fix:
Merge only missing index fields with commit-time tombstone pruning, make restores and deletes tombstone-first, commit compound topic mutations atomically, preserve unknown legacy metadata, surface authoritative read failures, and rebuild corrupt stores from session metadata.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race . -count=1
- go test -race ./internal/topicstate -count=1
- golangci-lint run ./...
- cd desktop && golangci-lint run ./...
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend test:typecheck
- go run ./tools/repolint
- git diff --check

* fix(desktop): replay pending topic mirrors first

Problem:
A crash between legacy JSON mirror writes could leave a mixed snapshot that was merged back into SQLite on restart.

Root cause:
Startup reconciled legacy digests before honoring the persisted pending mirror revision, so stale creation or auto-title metadata could appear to be an old-version edit.

Fix:
Replay the authoritative pending SQLite snapshot before legacy reconciliation, skip parsing partial legacy files during that replay, and cover the restart window with a regression test.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race . -count=1
- focused pending-mirror and corruption recovery race tests
- golangci-lint run ./...
- cd desktop && golangci-lint run ./...
- go run ./tools/repolint
- git diff --check

* fix(desktop): keep pending topic writes authoritative

Problem:
When a legacy mirror remained unavailable, the next topic mutation could silently fall back to JSON only and later be overwritten by SQLite.

Root cause:
Pending-mirror repair errors escaped the reconciliation path and activated the migration-only legacy write fallback before the new SQLite mutation ran.

Fix:
Keep serving and mutating the authoritative SQLite store while its mirror outbox remains pending, skip reconciliation against the known-partial JSON snapshot, and verify a second write survives restart.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- focused pending-mirror tests under the race detector
- golangci-lint run ./...
- cd desktop && golangci-lint run ./...
- go run ./tools/repolint
- git diff --check

* fix(desktop): serialize topic title publication

Problem:
A manual rename could commit after an automatic title but still have its Tab and session metadata overwritten by the automatic publisher.

Root cause:
SQLite compare-at-commit protected the durable row, but manual and automatic operations published their post-commit side effects without a shared ordering boundary.

Fix:
Serialize each title commit with its Tab/session publication, add a deterministic race regression, and document the direct-downgrade boundary for fresh SQLite-only scopes.

Verification:
- cd desktop && go test ./... -count=1
- focused title race tests under the race detector
- go test ./... -count=1 (unrelated historycatalog timeout passed on focused retry; two earlier full runs passed)
- golangci-lint run ./...
- cd desktop && golangci-lint run ./...
- go run ./tools/repolint
- git diff --check
2026-08-27 15:12:59 +08:00
SivanCola df2bd62438 Stabilize Windows transcript selection rendering / 稳定 Windows 对话选区绘制 (#9473)
* fix(desktop): stabilize Windows transcript selection overlay

Problem:
Repeated native multi-click selection in a Win11 WebView2 transcript could leave table pixels apparently displaced even though transcript scroll geometry did not change. The diagnostic bundle reported b9cf32f rather than the v1.31.4 tag SHA 97411a3, but the affected selection and table code remained equivalent through the tested main-v2 base.

Root cause:
The transcript action menu used an unstable compositor path: native selection churn repeatedly mounted and removed a translucent, blurred, transformed body portal. Native baseline replay confirms the unstable host lifecycle, although the current WebView2 runtime did not reproduce the original large stale-pixel region, so this does not claim a unique confirmed compositor root cause.

Fix:
Keep one state-driven transcript action portal for the component lifetime, reject stale positioning revisions, and make hidden states non-interactive and inaccessible. Use an opaque, non-blurred, non-transforming paint path only on Windows transcripts while preserving the existing macOS/Linux treatment. Add deterministic table fixtures, browser geometry coverage, a tagged native WebView2 capture host, image comparison, and a Windows CI gate.

Verification:
- pnpm --dir desktop/frontend test:transcript
- pnpm --dir desktop/frontend test:transcript-browser
- pnpm --dir desktop/frontend test:motion
- pnpm --dir desktop/frontend build
- cd desktop && go test ./...
- Win11 WebView2 native smoke: 3/3 at 1200x800 and 20/20 at 1920x1032
- Win11 vendored go-webview2 tests
- Windows amd64 and arm64 smoke-host cross-builds
- go run ./tools/repolint
- git diff --check

* fix(ci): satisfy transcript smoke lint

Problem:
The new Windows transcript pixel comparator tripped the required Go lint jobs, leaving desktop, desktop-macos, and lint red.

Root cause:
The comparator used a manual mask-membership loop and its tests used classic counted loops that the repository's modernize and intrange rules reject.

Fix:
Use slices.ContainsFunc for mask membership and Go integer ranges in the focused comparator tests without changing pixel thresholds or comparison behavior.

Verification:
- go test ./internal/transcriptsmoke
- go vet ./internal/transcriptsmoke
- GOOS=windows golangci-lint run --timeout=5m ./...
- go test ./...
- go run ./tools/repolint
- git diff --check
2026-08-27 14:56:07 +08:00
+7 36d418e639 feat(serve,desktop): multi-session detach, gzip snapshot transport, and credential watchdog / 多会话后台切换、gzip 快照传输与凭证通道 watchdog [2/3] (#9317)
* feat(desktop-fe): add remote tab bindings and browser mocks / 增加远程项目 Tab 的前端绑定与 mock

* feat(desktop-fe): offer remote connection in the add-project menus / 加项目菜单增加远程连接入口

* feat(desktop-fe): add three-step remote connection wizard / 增加远程连接三步向导

* feat(config): pin remote projects in user config / 用户配置支持固定远程项目

[[remote.projects]] entries (host_id + workspace + title) reference
[[remote.hosts]] by name. RemoteProject/UpsertRemoteProject/
RemoveRemoteProject with Clone copy and user-scope TOML rendering;
round-trip covered in remote_config_test.go.

* feat(desktop): remote project registry and remote tab bootstrap / 远程项目注册与远程标签引导

- RemoteTabRef discriminator on TabMeta and ProjectNode.
- List/Add/RemoveRemoteProjects persist through [remote].projects;
  AddRemoteProject dedupes on (hostID, workspace).
- remoteProjectNodes appends remote project groups to the tree
  snapshot; config read failures degrade to no remote nodes instead
  of breaking the tree.
- OpenRemoteProjectTab creates-or-focuses an in-app remote tab and
  returns meta immediately; SSH + Serve bootstrap runs in the
  background and reports through remote-tab:{id}:state events.

* feat(desktop-fe): remote project groups in the project tree / 项目树渲染远程项目分组

ProjectNode.remote drives the cloud folder icon; clicking an empty
remote group row re-enters the project via OpenRemoteProjectTab
(in-flight double clicks guarded by a ref Set, settled re-opens
deduped by the backend focus path). Browser mock mirrors the Go
snapshot so the tree shows pinned remote projects in dev.

* fix(desktop-fe): wizard suggest-list z-index token; failed-connect test / 向导建议列表 z 层级 token 化并补连接失败测试

.remote-wizard__suggest-list joins the z-scale tokens instead of a
hardcoded z-index: 2. The wizard test now drives a real
stopped+error → retry → connected sequence so the connecting step's
failure path stays observable instead of always succeeding.

* fix(desktop-fe): wizard all-or-nothing finish, connect log target, dead i18n keys / 向导完成回退、连接日志目标与清理死键

- finish(): a failed OpenRemoteProjectTab rolls back the just-registered project (RemoveRemoteProject); if rollback fails, refresh keeps the tree honest. AddRemoteProject failures return before any open attempt.
- connect(): target host comes from the caller, fixing the stale-closure log that printed a raw host id on first connect.
- Drop unused remoteWizard.folderName/createFolder keys from en/zh/zh-TW; wizard header comment no longer claims mkdir.

* feat(desktop): remote tab serve bridge — auth handshake, session entry, SSE pump / 远程标签 Serve 桥:认证握手、进入会话与事件泵

- serveHandshake: one POST /auth/token trades the pre-shared token for the
  HttpOnly session cookie (same flow the web shell uses); the token never
  enters a request line or the serve access log.
- enterRemoteSession: POST /new for fresh sessions; SessionName opens
  resolve the name against GET /sessions and POST /resume with the entry
  PATH (serve takes path, not name).
- remoteTabPump: subscribes GET /events and forwards each data frame
  verbatim as remote-tab:{id}:event; generation guard lets a superseded
  pump self-exit once reconnect logic lands.
- bootstrapRemoteTab now attaches the bridge after EnsureServer: handshake
  → pump subscribed → session entered → state=ready, bounded by a 30s
  sub-context.
- remoteEventHook test seam; 4 httptest bridge tests incl. pump cleanup
  (uncancelled /events connections otherwise hang httptest Close).
- Drop plan-task references from code comments (app.go, remote_projects.go,
  types.ts).

* feat(serve,desktop): model/effort over HTTP; full remote command proxy / Serve 开放模型与努力度路由,桥补全命令代理

- serve: POST /model and POST /effort expose the existing switchModel /
  switchEffort internals to HTTP clients (desktop remote tab + web shell
  pickers). Route validation tests; switch semantics stay covered by the
  existing model-switch suites.
- bridge: SetRemoteTabModel/Effort/PlanMode, Compact/Fork/Summarize/
  ForgetRemoteTab, RemoteTabBranches/Skills — one forward each through the
  shared remoteTabPost/remoteTabGet helpers. auto-approve-tools and bypass
  are deliberately skipped: they are legacy aliases of
  tool-approval-mode=yolo.
- bridge.ts: matching AppBindings declarations + mock stubs; fakeServe
  gains the new endpoints and the forwarding table gains 8 rows.

* feat(desktop): remote tab reconnect lifecycle and close / 远程标签断线自愈与关闭

- CloseRemoteTab: idempotent teardown — pump stops, registry entry drops,
  serve + SSH stay for other tabs.
- onStatus now drives open tabs through SSH transitions: connecting/
  reconnecting suspends pumps (gen bumped so stale pumps exit), connected
  re-attaches each tab to the still-running remote serve (fresh handshake
  + pump, no session re-entry needed — the serve holds the session),
  stopped parks tabs in error with the host failure text.
- Unexpected stream death now flags reconnecting and attempts one
  re-attach instead of reporting a hard error; commands on tabs that are
  reconnecting or failed fail fast with a clear message.
- Tests: close idempotence, host drop → reconnect → ready with a fresh
  event stream, terminal failure text propagation.

* feat(desktop): remote tabs join the strip with a native surface / 远程标签进入标签栏并渲染原生表面

- ListTabs merges open remote tabs; a highlighted remote tab deactivates
  local entries so exactly one strip item is active.
- SetActiveTab routes by registry membership: remote ids take the strip
  highlight, local ids clear it and run the existing path. CloseTabWithPolicy
  tears a remote tab down without touching the local runtime.
- OpenRemoteProjectTab emits remote-tab:opened with the meta on open and
  re-focus; the frontend adopts it (seedActiveTabMeta + switchTab) and
  renders RemoteSessionSurface instead of the local transcript when
  activeTab.remote is set.
- useRemoteSession: state/event subscriptions, snapshot hydration with
  retry (covers the mount-vs-event race), frame collection, and typed
  submit/cancel/approve/answer wrappers. Surface renders the four
  connection states; hydrated content wins over a still-pending state
  event. Styles use design tokens; locales in en/zh/zh-TW.
- Tests: ListTabs/activation/close integration (Go), surface + hook
  contract (tsx).

* feat(desktop-fe): remote transcript reuses the local pipeline end to end / 远程会话端到端复用本地渲染管线

- useRemoteSession feeds serve frames (same wire form as agent events)
  through the exported local reducer, hydrates /history through the local
  history action, and injects the optimistic user bubble with the local
  user action — items, live streaming, approvals, and asks therefore shape
  exactly like a local tab.
- RemoteSessionSurface renders the shared Transcript component; only the
  connection state machine and the approval/ask cards stay remote-specific.
  The composer stays the shared local one with send/cancel/running fed by
  the session (App-level wiring). Bespoke row/composer rendering and its
  styles/strings are gone; the mock snapshot returns an empty history like
  a fresh local session and the mock turn streams through the reducer.
- State type exported from useController; the svg asset stub also covers
  .css so tsx tests can mount the transcript chain.

* feat(desktop-fe): remote sessions in the project tree / 项目树远程会话行与远程菜单

- Remote groups list their serve sessions (RemoteProjectSessions) while
  the host is connected; rows open by sessionName through the ensure-open
  path. A new session invalidates the cached listing.
- Group row gains a connection badge driven by the live host status.
- Remote groups swap the local project menu for remote actions: new remote
  session, open remote window, stop remote server, unpin (removes the
  registration and refreshes). The + action opens a remote session instead
  of creating a local topic — local reveal/rename/remove no longer fire on
  remote rows.
- Source-contract test pins the tree wiring and the shared-composer prop
  branching in App.

* feat(desktop): session titles, new-session reset, and demo preseed / 会话标题、新建会话重置与演示预置

- The SSE pump schedules a title refresh after turn_done: the serve's
  LLM-generated title for the current session replaces the workspace-name
  default and rides the tab-opened channel, which the chrome merges into
  the existing strip entry (no re-switch when the tab is already active).
- A NewSession open on a live tab now POSTs /new: the previous session
  stays in the remote history list, the tab switches to the new empty one
  (title resets), and the re-emitted ready state forces the frontend to
  re-sync its snapshot — which now happens on every ready transition, not
  just the first hydration.
- The tree hydrates host statuses once on mount, so badges and the
  session-fetch gate are correct without a wizard run.
- Browser-dev mock preseeds a connected demo project, an open demo tab,
  and a demo session row; the canned turn adopts an AI-style title and a
  new-session open resets the demo tab like the real backend.
- Tests: title adoption + new-session reset (Go), ready re-sync (surface),
  preseed contract.

* feat(desktop): authoritative remote session lifecycle / 远程会话生命周期权威化

- The session listing is backend-authoritative: RemoteProjectSessions
  merges a desktop-view blank row (name empty, localized 新的会话 title)
  while the tab holds a fresh session the serve has not written yet, so
  the tree renders one source of truth.
- Blank reuse mirrors EnsureBlankTab: a sessionReset flag on the tab makes
  further new-session opens re-activate instead of POSTing /new again; the
  flag clears when the session earns a serve title or a resume lands.
- resumeRemoteTabSession switches a live tab to a listed session (POST
  /resume, title adoption, ready re-emit); the bootstrap gives fresh
  sessions the localized default title.
- serve /sessions entries carry mtimeMilli; the listing orders pinned
  first and merges desktop-owned title overrides; SetRemoteSessionPinned
  and SetRemoteProjectTitle back the tree actions.
- fakeServe mirrors serve semantics (/new abandons current, /resume
  restores it); tests pin the blank-reuse contract and resume flow.

* feat(desktop-fe): remote sessions ride the local renderer with full parity / 远程会话全面复用本地管线

- Session rows join the TREE DATA as synthetic topic nodes, so filtering,
  workbench arrangement, and the pinned section treat them exactly like
  local topics; clicks, pin, rename, and archive route to the remote
  bindings and the blank row comes from the authoritative listing (the
  frontend blank state machine is deleted).
- Project menus mirror the local templates item-for-item (new session,
  rename project via registry title, reveal in remote file manager, copy
  path, archive active conversation, unpin) in classic and workbench
  variants; AI rename stays disabled for remote rows.
- The shared Composer serves remote tabs: send/cancel/running from the
  session hook, model switch through SetRemoteTabModel with the capsule
  label from the serve status; the topic-bar pencil opens rename.
- The mock mirrors the whole chain: demo preseed (project, tab, session
  row with an 18-turn history), dynamic session store, local-script
  canned turns with cancellation, immediate 202-style submit resolve,
  session-interrupting switches, and model catalog switching.

* test(remote): multi-workspace serve coexistence coverage / 多工作区 serve 共存测试覆盖

- One fake host, two workspaces: slug-keyed state/token files coexist,
  re-ensure reuses only its own serve, Stop(A) leaves B alive, and each
  launch script carries only its own concrete state paths (plan task 1.1
  coverage over the as-built layout).

* feat(desktop): wizard-level remote platform check / 连接向导阶段的远程平台检查

- CheckRemotePlatform runs uname through the same ParseUname gate
  EnsureServe applies; the connect wizard awaits it at the connecting
  step, so an unsupported remote OS (V1: Linux/macOS) fails with one
  clear message before directory browsing starts, instead of deep in
  serve bootstrap after the project is registered.

* refactor(desktop): per-workspace remote serve registry and stop/status/logs API / 按工作区键控的远程 serve 注册表与停止状态日志接口

- managedHost tracks one serveEntry per workspace instead of a single
  server+token; the loopback tunnel is named serve-<slug> from
  store.RemoteWorkspaceSlug so one host holds one independent forward
  per workspace.
- StopServer/ServerStatus/ServerLogs gain a workspace parameter; the
  bound StopRemoteServer/RemoteServerStatus/RemoteServerLogs follow,
  with every frontend call site updated in the same commit because
  remote:server events now interleave per workspace.
- The web window records its workspace so reconnect refresh re-ensures
  it and stop closes it only on a match; the frontend store nests
  servers[hostId][workspace] and the panel server tab manages the
  input's workspace (the host's first entry while the input is empty)
  (plan task 2.1).

* test(desktop): assert StopRemoteServer forwards the workspace to the kernel / 断言 StopRemoteServer 向内核转发工作区参数

- Wires the fake kernel's stoppedWorkspaces tape to a real assertion in
  the stop-closes-window test, so the bound method's workspace
  parameter is verified alongside the window behavior.

* fix(desktop-fe): type-safe remote test mocks / 远程测试桩类型修复

- The optimistic-bubble assertion wraps its optional-chained some() in
  Boolean so ok() receives a boolean; the panel mock's window.go App
  object casts through Partial<AppBindings> like the wizard mock, and
  the wizard's Promise.withResolvers timers become plain Promise
  constructors (lib-target independent). These are what editors report
  when they type-check the tsconfig-excluded test files.
- The default React imports stay: tsx compiles these excluded files
  with the classic JSX transform, so the seemingly unused import is
  required at runtime.

* feat(desktop): remote tabs persist and restore as disconnected shells / 远程标签持久化与断连空壳恢复

- desktop-tabs.json gains remoteTabs + remoteTabOrder (omitempty: no
  remote tabs open ⇒ byte-identical legacy file). Entries carry only
  id/host/workspace/title — no session: clicking a restored shell
  reconnects and lands in a fresh blank session, and the tree group's
  session rows remain the picker for past conversations.
- restoreOrBuildTabs rebuilds shells in a disconnected state (no
  connect, no serve) and routes a persisted remote active id to the
  strip highlight; SetActiveTab on a shell and the tree-group
  ensure-open both bootstrap the reconnect in place, reusing the id.
- New disconnected state rides TabMeta.remoteState so the surface
  renders before any event this run; the surface offers a reconnect
  button; host status transitions never flip a shell into a runtime
  state.
- ReorderTabs accepts the full mixed strip: local and remote
  subsequences validate and persist separately (drag-reorder with
  remote tabs used to fail server-side). Single-surface layouts
  collapse remote shells to the active one like local tabs (plan
  task 3.1 remainder: Guards/Restore/Round-trip).
- The surface test's OpenRemoteProjectTab mock returns a TabMeta to
  match the binding signature (the void return broke the
  Partial<AppBindings> cast under editor type-checks).

* fix(desktop): boxed frame collapse

Fix \boxed rendering so the frame is a proper rectangle instead of a
collapsed vertical bar in WKWebView, and pin pnpm to v10 as required by
the frontend's engines field.

- KaTeX draws \boxed's frame as an empty .fbox span whose width comes
  from .katex-stretchy's width:100%. Under WKWebView that percentage
  resolves against the span's intrinsic width (0) inside the
  inline-table/table-layout:fixed vlist, so top/bottom borders vanish
  and left/right borders merge into one tall bar. Override the width
  with -webkit-fill-available, which fills the containing row while
  keeping the span's inline-block baseline alignment (vertical position
  intact).
- Add "packageManager": "[email protected].5" so Corepack/Wails use pnpm 10,
  matching the ">=10 <11" engine range, instead of defaulting to pnpm 11.
- TUI path: teach internal/cli/latex.go \boxed (render inner content),
  \left./\right. (invisible dot delimiter), and \vert/\lvert/\rvert/
  \Vert/\lVert/\rVert symbols, with regression cases in latex_test.go.

* fix(remote): generous serve/upload budget and pipelined SFTP writes / 放宽 serve 上传时限并流水线化 SFTP 写入

- The CLI serve/fs paths had a flat 60s deadline; a same-platform
  binary upload is ~55MB and real links sit under 1MB/s, so the
  deadline failed exactly the install mode offline hosts need. Both
  budgets rise to 10 minutes.
- sftpfs copies uploads through a 1MB buffer so each Write keeps ~32
  SFTP packets in flight; with the default 32KB io.Copy buffer every
  Write is one packet and concurrent writes degenerate to a packet
  per round-trip. (A pkg/sftp throughput gap vs OpenSSH sftp remains
  on high-RTT links: measured 25-40KB/s vs 588KB/s; tracked
  separately.)

- List resolves a leading "~" server-side (Stat-verified, falling back
to the session start directory): the SFTP protocol does not expand it,
so the wizard's fresh-host browse of "~" failed with
"file does not exist".

* feat(remote): local-proxy credential mode kernel / 本地代理凭证模式内核层

- RemoteHostEntry gains credential_mode (remote | local-proxy) with
  validation, renderer, and a --credential-mode flag on remote add.
- bootstrap.CredentialProxyOptions: EnsureServe installs a
  tunnel-backed provider entry into the remote user config
  (idempotent append; a base_url change rewrites just that line) and
  LaunchCommand injects the virtual token via the serve environment
  plus --model <provider> — the token never rides argv or files.

* feat(desktop): desktop-held credentials for remote serves / 远程 serve 桌面持钥模式

- credential_proxy.go: a loopback key holder swaps the per-host virtual
  token for the real provider key and streams responses through
  (SSE-safe). Tokens derive from a persisted secret, so a restarted
  desktop keeps working with a reused remote serve; rotating the
  secret revokes every token at once.
- EnsureServer orchestrates local-proxy hosts: proxy starts, the
  cred-proxy:<host> reverse tunnel opens (forward.Remote, idempotent),
  and the bootstrap receives the token + provider entry to install.
- Host form gains the credential-mode select (three locales); the
  locale budget gate rides along (remote copy grew the chunks past
  the old 55.8/55.0 gates).
- E2E: TestCredentialProxyRealHostSmoke (env-gated) drives connect →
  reverse tunnel → serve launch → one real model turn and asserts the
  reply arrived through desktop-held credentials; verified against a
  live host.

* fix(desktop-fe): tolerate the wails dev spinner crash / 容忍 wails dev 转圈组件崩溃

- index.html gains the #wails-spinner anchor expected by the v2.13 dev
  runtime.
- The global crash screen now ignores errors whose stack lives entirely
  inside /wails/*.js: the v2.13 dev runtime's Svelte spinner throws
  "Cannot read properties of null (reading 'nodes')" on mount while the
  app itself keeps working (verified headless: full UI renders), so the
  fatal report must not escalate third-party runtime noise.

* fix(remote): materialize the preset default_model relies on / 物化默认模型依赖的内置供应商

- Appending the credential-proxy [[providers]] entry replaces the
  remote's built-in provider defaults, so a default_model like
  "deepseek-flash" that resolved only through those defaults would
  dangle and crash the serve at startup (surfacing as a confusing
  port-file timeout). The install now materializes the builtin entry
  default_model refers to as an explicit block BEFORE ours; the
  default_model line itself is never rewritten, so every entry point
  (CLI, web window, remote-mode desktop launches) keeps resolving.

* feat(desktop-fe): credential-mode segmented control in the wizard / 向导增加凭证模式分段控件

- The wizard's inline host form gains the same remote|local-proxy choice
  as the hosts page, rendered as a provider-add-segmented control after
  the download method (established pattern; zero new styles or locale
  keys). Wizard-created hosts no longer silently default to remote mode.

* fix(remote): heal the credential provider config on reuse too / 复用 serve 时同样治愈凭证配置

- ensureCredentialProvider now runs before the reuse check, so a live
  serve's config gains the materialized default_provider entry as well —
  previously only fresh launches installed it, leaving reused serves on
  boxes with the old dangling-default config.

* fix(desktop): ephemeral reverse-tunnel port for the credential proxy / 凭证代理反向隧道改用临时端口

- The remote bind drops the fixed 18999 in favor of 127.0.0.1:0 (the
  SSH layer reports the actual port; the materialized provider entry's
  base_url already rewrites on port change). A half-dead session's
  stale listener can no longer block serve starts with a confusing
  "tcpip-forward request denied".

* fix(config): relax compact_ratio floor from 0.65 to 0.30

Allow users to set a lower compaction threshold for large context
windows (e.g. 1M). A lower threshold triggers compaction earlier,
giving users finer control over cost vs. context freshness tradeoffs.

The existing safety guards (Prepare boundary, foldEconomics) remain
unchanged — lowering the floor only expands the user-configurable
range, not the automatic behavior.

Fixes #9060

* feat(serve,agent): provider reload endpoint, provider_unreachable event, status reconciliation fields / serve 增加供应商重载端点、通道不可达事件与状态对账字段

* feat(remote): credential heal closes the loop — change signal and .env token delivery / 凭证治愈闭环:变更信号与 .env 令牌交付

* feat(desktop): reverse-channel self-healing, single-pump attach, busy-soft session entry / 反向通道自愈、单泵附着与忙碌软进入

* feat(desktop-fe): remote session running reconciliation, submit rollback, interruption notices / 远程会话运行对账、提交回滚与中断通知

* feat(desktop-fe): remote project tree and wizard polish / 远程项目树与向导打磨

* fix(desktop-fe): make remote-session-surface test runnable under the default runner / 修复远程会话面测试在默认跑测器下的崩溃

- 按 remote-server-panel 的既有模式把 remote-session-surface.test.tsx
  注册到 test:remote(svg-stub-register 加载):其渲染链经 Transcript
  懒加载 ScrollDiagnosticPanel,再传递导入 logo-wordmark.svg,裸 tsx
  无法解析该扩展名
- ScrollDiagnosticPanel 读 import.meta.env.DEV 改为可选链:裸 tsx 下
  import.meta.env 为 undefined(此前 __BUILD_CHANNEL__ 未定义即开启
  诊断面板,模块求值即崩);Vite 构建行为不变
- 验证:svg-stub 跑测 24/24 通过,tsc 无告警

注:remote-error-ux.test.tsx 的 5 个安全摘要断言失败为分支合并前
既有问题(d597b10fd 上同样 4 过 5 挂),与本次合并无关。

* feat(serve): multi-session detach, runtime status, and gzip snapshot transport / 多会话后台切换、runtime 状态与 gzip 快照传输

- Add detached multi-session controllers and lease split/adopt so busy
  sessions keep running across switches, with session-tagged SSE frames
- Expose GET /status?runtime=1 for desktop hydrate/reconcile without
  wallet Balance, aligning remote running checks with local RuntimeStatus
- Compress large snapshot JSON responses with gzip middleware
- Keep provider reload retiring stale detached controllers after
  credential-channel heal

Verification: go test ./internal/serve -count=1 -run 'TestStatusRuntimeQuerySkipsBalance|TestProvidersReloadRetiresStaleDetached|TestGzipMiddleware'

* feat(desktop,remote): credential-channel watchdog and provider heal / 凭证通道 watchdog 与供应商配置自愈

- Watch the reverse credential tunnel at runtime and re-heal when the
  ephemeral remote port drifts after SSH reconnects
- Rewrite the remote reasonix-desktop-proxy base_url through
  HealCredentialProvider, then reload serve providers (cancel-then-reload
  when a doomed busy turn blocks 409)
- Gate bootstrap reuse on session-events / detached-heal capabilities so
  stale serves are replaced instead of fast-reused on a dead loopback port

Verification: go test ./desktop -count=1 -run 'TestCredentialChannel|TestNeedsHeal|TestCancelThenReload' ; go test ./internal/remote/bootstrap -count=1 -run 'Credential|Heal'

* fix(desktop): remote session snapshot race, hydrate slimdown, and bridge-chain acceptance / 修复远程会话快照竞态并瘦身 hydrate

- Stop RemoteTabSnapshot concurrent map writes by copy/merging the per-tab
  ETag cache under remoteTabMu instead of sharing it across unlocked workers
- Let hydrate/reconcile fetch only /history + /status?runtime=1, coalesce
  overlapping ready/mount hydrates, and rate-limit REFUSED command logs
- Add bridge-chain acceptance probes (Open→ready→snapshot stampede) and FE
  surface assertions so verification walks the real frontend binding path

Verification: go test ./desktop -race -count=1 -run 'TestRemoteTabSnapshot|TestRemoteSessionHydrateChainFromOpenToSnapshot|TestServeGetCachedAcceptsGzip'; node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx

* feat(desktop,cli): remote session-path switch and serve capability markers / 远程会话按路径切换与 serve 能力标记

- Pass listing-row sessionPath/sessionTitle into OpenRemoteProjectTab so
  resume skips a redundant GET /sessions name lookup
- Project live turn state onto remote session rows from tagged runtime
  events, including background sessions
- Advertise --session-events / --detached-heal (and the rolling caps token)
  from serve --help so remote bootstrap can replace outdated binaries

Verification: go test ./desktop -count=1 -run 'TestResumeRemoteTabSessionKnownPathSkipsListing|TestRemoteTabPumpRoutesFramesBySession'; go test ./internal/cli -count=1 -run Serve

* fix(desktop-fe): pin test locale to jsdom navigator / 测试固定 jsdom navigator 避免系统语言影响断言

* fix(desktop-fe): add missing readOnly to local-only recovery fixtures / 补齐 local-only 恢复用例缺失的 readOnly 字段

* fix(desktop-fe): pin test locale to jsdom navigator / 测试固定 jsdom navigator 避免系统语言影响断言

* fix(desktop-fe): add missing readOnly to local-only recovery fixtures / 补齐 local-only 恢复用例缺失的 readOnly 字段

* chore(desktop-fe): ratchet raw budget for the serve-scaling stacked tip / 上调原始体积预算适配 serve 扩展层

* fix(sessioncatalog): stop repeated repair reindex loop / 修复会话目录反复重新索引 (#9312)

* fix(sessioncatalog): stop repeated repair reindex loop

Problem: Idle desktop sessions could repeatedly show indexing progress and make sidebar deletion appear unavailable.\n\nRoot cause: Exact-path catalog indexing rewrote an unchanged projection and published a new revision on every defensive snapshot, allowing repair and reconcile work to feed back into one another.\n\nFix: Skip unchanged exact-path projections, prevent stale unknown inputs from downgrading known counts, and add regression coverage for repeated indexing and repair completion.\n\nVerification: go test ./internal/sessioncatalog -count=1; go test -race ./internal/sessioncatalog -count=1; go vet ./internal/sessioncatalog; cd desktop && go test ./... -run 'Test(DeleteSession|SessionCatalog|ProjectTree|Reconcile|ListProjectTopics)' -count=1

* fix(sessioncatalog): preserve recovery metadata during index repair

Problem: the first PR revision failed repository standards and could leave recovery metadata stale when an unknown count record arrived for an existing session.\n\nRoot cause: the exact-index fast path was embedded in the large upsert function, and its downgrade guard skipped the whole record without distinguishing count fields from recovery metadata.\n\nFix: extract exact-path preparation into a focused helper, preserve known counts only when the content fingerprint is unchanged, and still apply recovery metadata updates.\n\nVerification: go run ./tools/repolint; go test ./internal/sessioncatalog -count=1; go test -race ./internal/sessioncatalog -count=1; go vet ./internal/sessioncatalog; cd desktop && go test ./... -run 'Test(DeleteSession|SessionCatalog|ProjectTree|Reconcile|ListProjectTopics)' -count=1

* fix: preserve sidebar activity order during exact indexing / 修复精确索引导致的侧栏会话错排 (#9314)

* fix: keep runtime conversation rows in canonical order

Problem: Switching conversations could reorder sidebar rows while runtime and catalog snapshots crossed.

Root cause: The frontend sort comparator treated runtime session rows as incomparable with catalog topic rows, so async arrival order leaked into the visible order.

Fix: Sort both row shapes with the same pinned, manual, activity, and stable-key rules, while keeping non-conversation nodes deterministic. Add a regression test covering a runtime-only row ahead of catalog rows.

Verification: Focused project-tree runtime, catalog-completeness, and runtime-projection tests; frontend typecheck.

* test: keep project tree regression within file budgets

Problem: The focused regression pushed project-tree-runtime.test.ts past the repository test-file budget.

Fix: Move the sorting regression into the dedicated project-tree-sort-runtime test module without changing production behavior.

Verification: Project-tree runtime tests, repolint, and frontend typecheck.

* fix(sessioncatalog): preserve recovery metadata during index repair

Problem: the first PR revision failed repository standards and could leave recovery metadata stale when an unknown count record arrived for an existing session.\n\nRoot cause: the exact-index fast path was embedded in the large upsert function, and its downgrade guard skipped the whole record without distinguishing count fields from recovery metadata.\n\nFix: extract exact-path preparation into a focused helper, preserve known counts only when the content fingerprint is unchanged, and still apply recovery metadata updates.\n\nVerification: go run ./tools/repolint; go test ./internal/sessioncatalog -count=1; go test -race ./internal/sessioncatalog -count=1; go vet ./internal/sessioncatalog; cd desktop && go test ./... -run 'Test(DeleteSession|SessionCatalog|ProjectTree|Reconcile|ListProjectTopics)' -count=1

* fix: preserve activity during exact session indexing

Problem: A transient older sidecar read could overwrite a known newer activity timestamp and reorder the project sidebar while switching conversations.

Root cause: Exact-path indexing replaced the catalog activity projection without comparing it with the existing authoritative content projection.

Fix: Preserve the known activity timestamp when the transcript fingerprint is unchanged and the incoming sidecar timestamp is older. Add a deterministic regression test for the backwards-activity case.

Verification: Focused sessioncatalog tests, project-tree runtime/sort tests, frontend typecheck, repolint, and git diff --check.

* refactor: remove unreachable runtime session sort path

Problem: The frontend sorter change targeted top-level session rows that the current runtime projection does not emit.

Fix: Remove the unreachable presentation change and its misleading fixture so the PR owns only the exact-index activity regression that reproduces the reported sidebar reorder.

Verification: Project-tree tests, sessioncatalog tests, repolint, and frontend typecheck.

* fix(desktop): allow deleting configured default providers / 修复已配置默认模型服务无法删除 (#9344)

* fix(desktop): allow deleting configured default providers

Problem: The provider access menu was disabled for any custom provider selected as the current default, so configured providers could not be deleted from settings.\n\nRoot cause: The frontend blocked the action based on default-model state even though the backend already retargets references to a configured fallback and rejects unsafe removals.\n\nFix: Remove the frontend-only default-provider deletion gate and keep the backend as the source of truth. Add a regression test covering the custom provider removal confirmation flow.\n\nVerification: provider access card test, frontend typecheck, test typecheck, ESLint, CSS checks, and desktop Go tests all pass.,

* test(desktop): cover deleting the current default custom provider

Problem: the provider deletion regression test did not model a custom provider selected as the current default. Root cause: the card-only fixture bypassed SettingsPanel and never supplied a defaultModel reference. Fix: render the real models/access settings path with a default custom provider and verify the delete action reaches RemoveProviderAccesses. Verification: provider-access-card test, test typecheck, frontend typecheck, targeted ESLint, CSS checks, and settings-refresh snapshot test all pass.

* fix: restore explicit readiness recovery and stabilize transcript scroll / 恢复显式收尾恢复并稳定会话滚动 (#9341)

* fix(control): restore explicit readiness recovery

Problem: Standard and Delivery turns could launch hidden readiness continuations after a final answer, causing extra model rounds and useless work.\n\nRoot cause: ordinary orchestration armed automatic readiness continuation and classified task progress from per-turn control state and assistant prose.\n\nFix: stop ordinary Standard and Delivery turns at the readiness boundary, preserve explicit Delivery recovery and Goal/Plan continuation, and remove the task-progress continuation path and prose heuristic.\n\nVerification: go test ./internal/agent ./internal/control

* fix(desktop): stop freezing transcript rows during scroll

Problem: manual transcript scrolling could freeze recycled rows to estimated heights and maintain a second pending-Markdown geometry contract, allowing Virtuoso offsets and painted content to diverge.

Root cause: the scroll arbiter forced DOM height during measurement and the geometry cache calibrated unseen rows from unrelated samples.

Fix: keep real measurements for wheel and touch reading, remove pending Markdown fixed-height fallbacks, remove cross-row calibration, and route viewport-shrink tail pinning through the existing Virtuoso writer.

Verification: transcript measurement, native scrollbar, scroll-release, anchor-compensation, recovery-race, typecheck, and single-scroll-writer checks.

* fix(desktop): bound transcript tail writer

* test(desktop): align transcript tail writer fixtures

* docs: document explicit readiness recovery

* fix(desktop): restore explicit Delivery recovery copy

* test(desktop): clarify native scrollbar measurement contract

* fix(desktop): stabilize async markdown geometry

* fix(desktop): keep native scrollbar drag geometry stable

* fix(desktop): adopt bounded transcript scroll recovery

Problem: tail settling could re-arm on sub-row measurement jitter, and a near-bottom downward wheel could arm extent recovery that fought the user's scroll. The previous CI desktop failure also lacked enough geometry evidence and Windows had no browser replay of the transcript path. Legacy readiness comments still described removed ordinary-turn continuation.

Root cause: the tail writer had an attempt cap but no height-growth gate or pinned-state distinction, while releaseTailFollow armed reader extent unconditionally. The browser harness spawned pnpm with a POSIX-only command name and the CI contract counted transcript checks across jobs.

Fix: add a single 24px tail re-arm policy that only suppresses writes while the tail is actually pinned, keep displaced tail recovery live, add the #9248 near-bottom guard, and cover both in deterministic tests. Make transcript browser benches cross-platform, add a Windows Chromium replay beside the native WebView2 smoke, improve timeout diagnostics, and clarify Standard/Delivery readiness comments without changing wire fields.

Verification: go test ./...; go test -race ./internal/agent ./internal/control; cd desktop && go test ./...; cd desktop/frontend && pnpm test:transcript; pnpm test:transcript-browser (two stability runs); pnpm test:motion; pnpm typecheck; pnpm check:scroll-writer; pnpm build; go vet ./internal/agent ./internal/control; go run ./tools/repolint ./....

Co-authored-by: hanswu <[email protected]>

* fix(desktop): launch transcript preview on Windows

Problem: the Windows transcript replay reached the browser bench but failed before starting Vite with spawn EINVAL under Git Bash.

Root cause: Windows command shims such as pnpm.cmd need shell dispatch when launched by Node child_process on the runner.

Fix: keep the platform-specific pnpm command and enable shell dispatch only on Windows for both transcript browser benches.

Verification: cd desktop/frontend && pnpm test:transcript-browser; the Linux/macOS path remains direct spawn and the Windows CI job will rerun the replay.

* test(desktop): make native thumb endpoint platform aware

Problem:\nThe transcript browser replay passed its selection and geometry assertions on Windows but timed out while dragging the native scrollbar thumb to the physical bottom.\n\nRoot cause:\nWindows Chromium keeps a held native scrollbar thumb attached to the track, while Linux themes clamp after an overshoot beyond the track. A single overshoot endpoint is not portable.\n\nFix:\nUse the in-track endpoint on Windows and retain the Linux overshoot endpoint. The gate still exercises native thumb ownership, measurement freezing, release, and tail remeasurement.\n\nVerification:\n- cd desktop/frontend && node bench/transcript-scroll-stability.mjs\n- git diff --check

* test(desktop): skip unsupported Windows headless thumb drag

Problem:\nHeadless Chromium on Windows reports the reserved scrollbar gutter but does not expose a draggable native thumb, so the transcript replay timed out after all geometry and scroll assertions had passed.\n\nRoot cause:\nThe browser benchmark was asserting a WebView2/native scrollbar capability that is unavailable in the headless Chromium runner.\n\nFix:\nSkip only the native thumb drag portion on Windows unless explicitly enabled with REASONIX_TRANSCRIPT_NATIVE_THUMB=1. Keep the complete replay, Linux native-thumb gate, transcript-native-scrollbar unit coverage, and Windows WebView2 native smoke.\n\nVerification:\n- cd desktop/frontend && node bench/transcript-scroll-stability.mjs\n- git diff --check

---------

Co-authored-by: hanswu <[email protected]>

* fix: remove grouped custom provider routes atomically

Problem:
The OpenCode Go settings card submitted all of its custom routes for removal, but the backend rejected every multi-provider custom request.

Root cause:
Grouped deletion was restricted to official provider aliases even though OpenCode Go is presented as one product-level connection backed by several custom provider entries.

Fix:
Recognize the narrow OpenCode Go route family, expand stale requests to every installed family member, and reuse the failure-atomic provider removal and runtime retargeting path for the full group. Unrelated custom batches remain rejected.

Verification:
- go test ./... (desktop module)
- go test -race focused grouped removal tests
- provider access card component test
- frontend production and test TypeScript checks

* test: split provider group removal coverage

Problem:
The grouped-removal regression coverage pushed an existing desktop test file beyond the repository size ceiling.

Root cause:
The new OpenCode Go tests were added to the already-large general provider-removal test file.

Fix:
Move the new grouped custom-provider tests into a focused test file without changing their behavior.

Verification:
- go run ./tools/repolint
- cd desktop && go test ./...
- focused grouped-removal tests
- focused grouped-removal tests under go test -race

* fix(windows): restore taskbar icon on the running desktop window

Wails v2 registers its window class (wailsWindow) without an hIcon, so the
window answers WM_GETICON with NULL. On Windows 11, once the process sets an
explicit AppUserModelID (PR #7925 -> SetCurrentProcessExplicitAppUserModelID
Reasonix), the taskbar prefers resources registered for that AUMID over the
executable's embedded icon; with none registered it renders the blank-document
glyph for the running window's button. Pinned shortcut icons are unaffected
(they read IconLocation -> reasonix-launcher.exe).

Set the first icon group from the running executable on the Wails window and
its class at startup: WM_SETICON (big+small) plus SetClassLongPtr(GCLP_HICON,
GCLP_HICONSM), which also covers Alt+Tab and window switcher rendering.
Loads via ExtractIconExW, retrying briefly until the wailsWindow appears.

Verified: go vet ./... and unit tests in desktop; plus an isolated Win32 A/B
repro (no-hIcon window class + explicit AUMID) showing the taskbar glyph goes
from blank-document to the embedded brand icon (taskbar screenshot saturation
0.456 -> 0.742).

Fixes the taskbar-icon half of the Windows desktop regression; grouping is
already handled by PR #7925.

* feat(desktop-fe): three-step remote connection wizard / 三步远程连接向导 [0/3]

- RemoteConnectWizard: host pick → auth/platform check → workspace
  browse, all-or-nothing finish, connect log rail, z-index tokens
- add-project menus offer remote connect (ProjectTree + useProjectCreation)
- bridge/types: remote tab bindings with browser mocks
- locales en/zh/zh-TW wizard keys; wizard styles

* feat(config): pin remote projects in user config / 用户配置支持固定远程项目 [0/3]

- config/remote.go: pinned remote project entries (host + workspace)
- render.go: render pinned projects; remote_config_test coverage

* feat(desktop): wizard-level remote platform check / 连接向导阶段的远程平台检查 [0/3]

- remoteKernel.CheckPlatform: uname through the same ParseUname gate
  EnsureServe applies; App.CheckRemotePlatform binding for the wizard
- wizard awaits the check at the connecting step, before directory
  browsing, failing early with one clear message on unsupported OS
- bridge binding + mock; platform-check tests (app/lifecycle)

* chore(desktop-fe): ratchet bundle budgets for the connect wizard / 连接向导预算 ratchet [0/3]

- initial JS 431.6→432.5, app-shell CSS 114.3→115.4, zh 56.5→57.2,
  zh-TW 57.2→58.0, raw startup 2353.2→2364.0 (measured at [0/3])

* feat(desktop): remote project registry and tree groups / 远程项目注册与项目树分组 [0.5/3]

- remote_projects.go: per-host remote project registry with config pinning
- session_catalog.go: remote tree nodes; tabs.go: WorkspaceTab.Remote ref
- app.go: registry bootstrap wiring
- ProjectTree: remote group rows with cloud badge, serve session rows
  and remote menu (opens/stop/unpin); session click lands with [1/3]
- bridge/types: RemoteTabRefView + OpenRemoteProjectTab bindings

* feat(desktop): per-workspace remote serve registry, stop/status/logs API / 按工作区键控的远程 serve 注册表与停止状态日志接口 [0.5/3]

- remote_app.go: serves keyed per workspace; StopServer/ServerStatus/
  ServerLogs take hostID+workspace; per-host registry map + serve entries
- remote_window/lifecycle tests; RemotePanel + remote-store + bridge
  bindings and mocks updated to the per-workspace signatures

* feat(desktop): remote session listing for the project tree / 项目树远程会话列表 [0.5/3]

- remote_listing.go (slice of the serve bridge, folded back into
  remote_tab.go by the sessions PR): one-shot auth handshake against an
  already-ready serve registration, GET /sessions, RemoteProjectSessions
- ServeSnapshot read-only lookup on the per-workspace registry: query
  paths must never cold-start a serve; live-tab fast path lands with [1/3]

* fix(remote): generous serve/upload budget and pipelined SFTP writes / 放宽 serve 上传时限并流水线化 SFTP 写入 [0.5/3]

- sftpfs pipelined writes with a generous upload budget; remote_connect
  follows suit; multi-workspace serve coexistence coverage

* feat(desktop): desktop-held credentials for remote serves / 远程 serve 桌面持钥模式 [0.5/3]

- desktop cred_proxy: virtual-token key holder with reverse-tunnel
  delivery; per-workspace routes, ephemeral remote ports, watchdog heal
- bootstrap: local-proxy credential kernel (provider install/heal +
  change signal, mismatched-serve replacement) and .env token delivery
- serve: provider-reload endpoint, provider_unreachable event, status
  reconciliation fields (agent/control/event/eventwire)
- wizard: credential-mode segmented control; hosts page + panel/store
  tests to final; sftp upload budget + multiworkspace coexistence

* feat(desktop-fe): wizard polish, tree styles, remote test hardening / 向导打磨、树样式与远程测试加固 [0.5/3]

- wizard credential-mode segmented control; connect-log and finish polish
- project-tree group/badge/menu styles (d597b10fd subset)
- remote-error-ux pinned to jsdom navigator (fixes the 5 pre-existing
  locale failures); hosts-page/secret-dialog/server-panel mocks to final

* chore(desktop-fe): ratchet bundle budgets at project groups / 项目分组段预算 ratchet [0.5/3]

- initial JS 432.5→434.5, app-shell CSS 115.4→115.8,
  zh 57.2→57.6 / zh-TW 58.0→58.6, raw startup 2364.0→2373.0 (measured)

* feat(desktop): remote tab serve bridge — handshake, SSE pump, lifecycle / 远程标签 Serve 桥:握手、事件泵与生命周期 [1/3]

- remote_tab.go: auth handshake, session entry, single-pump attach,
  reconnect/close lifecycle, full command proxy, snapshot; the listing
  slice from [0.5/3] folds back in here
- remote_projects/tabs/app/session_catalog: authoritative session
  lifecycle, titles, new-session reset, disconnected-shell persist and
  restore, reverse-channel self-healing

* feat(desktop-fe): remote sessions ride the local renderer with full parity / 远程会话全面复用本地渲染管线 [1/3]

- RemoteSessionSurface + useRemoteSession: transcript through the local
  pipeline end to end, strip activation/close, running reconciliation,
  submit rollback, interruption notices
- App: shared Composer wired to the remote session; ProjectTree session
  rows click-through, titles, merged-project wizard polish
- bridge/types to final; svg-loader, crash guard, index.html spinner fix

* feat(serve,remote): session-plane endpoints and final kernel state / 会话面端点与内核终态 [1/3]

- serve.go: session-plane endpoints, model/effort routing to final
- desktop/kernel + internal (agent/control/event/eventwire/config/cli/
  bootstrap/sftpfs) to final state

* test(desktop-fe): remote session suites, locales, styles closeout / 远程会话测试、语言与样式收尾 [1/3]

- remote-session-surface / remote-running-reconcile / persist suites
- transcript-rows, app-chrome-tabs, history-tool-status updates
- locales en/zh/zh-TW and styles.css to final; runner + budgets closeout

* docs: trigger docs-impact re-run

* fix(desktop): keep group visible when topic archive is rejected

* feat(desktop): add per-session archive action in project tree

* fix(desktop/control): interrupted jobs no longer block close/archive

* fix(agent): document memory: capabilities in use_capability description / 在 use_capability 描述中说明记忆工具用法

* perf(crash-worker): the dashboard's rarest number stops costing the whole D1 bill (#9379)

The preferences module's 30-day deduplicated install count was computed by an
hourly cron over metric_users. That table's primary key leads with `date`, so
`WHERE date >= ... AND signal = ?` could only prune the window: every signal
scanned all ~28M rows, eight signals an hour. One dashboard panel therefore
read on the order of five billion rows a day, which is the database's entire
read bill — and metric_users had no other reader.

Retire the detail table and everything that fed it: the rollup cron and its two
tables, the per-install metrics write, the dashboard's own full-table window
query, and the runtime bootstrap that would recreate cli_metric_users on the
next request. The aggregate `metrics` tables keep the same signals as event
counts at ~180k rows, and `pings` keeps per-day deduplicated installs.

Both panels that rendered the retired number are removed rather than left
showing a permanent error — the 30-day notice linked to a 7-day view that no
longer had data either. The client stops sending install_id with metrics, since
nothing receives it now.

migrate-drop-metric-users.sql reclaims the ~5.6GB the table held. Deploy the
worker before running it: until then the runtime bootstrap rebuilds the table.

* fix(jobs): stop calling quiet background jobs "stalled"

A background job with no output for tools.background_jobs.stalled_warning_seconds
(900s by default) was reported as "may be stalled". Many legitimate long-running
tasks (installs, builds, test collection, waiting on an external service) stay
silent for minutes without being stuck, so a single "no visible output" signal
cannot distinguish a hang from a healthy quiet job and produced noisy false
warnings.

Reframe the notice so it states the observable fact — still running after N
without new output, i.e. a heads-up, not an error — and points at how to tune or
disable the check (stalled_warning_seconds = 0). Applies to the drain note, the
emitted Notice Text/Detail, and the config comments and rendered output.

Tests: go test ./internal/jobs/ ./internal/config/ (all pass).

* fix(jobs): keep quiet-job notices within lint budget

Problem: the notice wording fix pushed jobs.go beyond the repository repolint baseline, and the updated tests did not fully guard the new Text/Detail contract.

Root cause: the notice payload was split across extra local variables and a multi-line event literal, while the tests only searched for the new headline.

Fix: construct the unchanged warning event in a compact value, and assert the new wording, guidance, and removal of the old stalled phrase in both emitted and drained notices.

Verification: go test ./internal/jobs/ ./internal/config/; go test -race ./internal/jobs; go vet ./internal/jobs/ ./internal/config/; go run ./tools/repolint.

Co-authored-by: SivanCola <[email protected]>

* fix: preserve scoped provider removal semantics

Problem:
Grouped custom-provider removal expanded search-only requests to every OpenCode Go route and left auxiliary model references pointing at deleted providers.

Root cause:
The batch API did not preserve the caller's requested subset, while the removal fingerprint and retargeting path covered only the primary desktop model references.

Fix:
Delete only the explicitly requested members of a validated OpenCode Go family. Clear vision references, retarget guardian/recovery and every persisted bot model owner, and include those values in the concurrent-change fingerprint.

Verification:
- go run ./tools/repolint
- cd desktop && go vet ./... && go test ./...
- focused grouped-removal tests under go test -race
- provider access card component test
- frontend production and test TypeScript checks

Co-authored-by: poorpaper <[email protected]>

* fix(desktop): guard boxed math width across browsers

Problem: KaTeX boxed frames can collapse in WebKit, and the frontend package pin and CSS contract were not guarded by an automated test.

Root cause: the WebKit-specific fill value had no standards-compatible fallback or source contract test.

Fix: keep width:100% as a fallback, retain -webkit-fill-available for WebKit, and add a discovery-run contract test for the boxed rule and pnpm pin.

Verification: go test ./internal/cli; go vet ./internal/cli; CSS syntax and z-index checks; math-golden; katex-boxed-css contract test.

* fix(config): expose compact ratio floor across all settings

Problem: the config setter accepted ratios below 0.65 while the CLI, desktop editor, mock bridge, tests, and docs still enforced the old floor.

Root cause: compact-ratio bounds were duplicated across Go and TypeScript surfaces.

Fix: centralize Go bounds at 0.30-0.85, apply the range to CLI and desktop settings, update localized guidance and documentation, and add lower-bound regression coverage including 1M-context trigger math.

Verification: go test ./internal/config ./internal/cli ./internal/agent; go test ./desktop -run TestSetCompactRatio -count=1; frontend settings snapshot, typecheck, and eslint pass.

Co-authored-by: SivanCola <[email protected]>

* chore(desktop): budget boxed math CSS headroom

Problem: the latest-base app-shell CSS budget was 30 gzip bytes below the boxed KaTeX fallback build.

Root cause: the rendering fix adds a small, intentional stylesheet rule at a rounded 114.3 KiB gate.

Fix: raise only the deferred app-shell CSS allowance to 114.4 KiB and document the measured 30-byte reason.

Verification: pnpm build passes with the updated budget.

* fix(mcp): dispatch host-session MCP servers supplied over ACP

An MCP server passed through ACP `session/new.mcpServers` arrives as
`Options.ExtraPlugins` and is appended to `eagerSpecs`, so it reaches
`pluginHost`: it connects, completes initialize + tools/list, and its tools
appear in the capability catalog as `status: "ready"`.

The capability runtime is a second, separate registry. It is seeded by
`capRuntime.ConfigureServers(cfg.Plugins, capSpecs, enabledMCPNames)`, and both
`capSpecs` and `enabledMCPNames` were built from config alone. A host-session
server was therefore absent from `r.servers`, so `serverEnabled()` returned
false on its `ok` check and every `mcp-tool:<server>/<tool>` call was refused
with "is disabled in this session" — while the catalog had just advertised the
same tool as ready.

Register host-session specs with the capability runtime and mark them enabled,
matching the intent already stated where they are built: they arrive through an
explicit host action, so they are authoritative exactly like an auto-started
config plugin. Both changes are required; neither alone is sufficient, since
`ConfigureServers` reads an absent `enabled[name]` as false and `serverEnabled`
rejects a name missing from `r.servers`.

Also separate "not registered" from "disabled" in the refusal message. They have
different causes and different fixes, and reporting the former as the latter
sent users looking for a toggle that does not exist.

Reproduced against v1.31.3 over the real ACP wire with zero `[[plugins]]` in
config: `use_capability` `action: "call"` on
`mcp-tool:acppassed/traycer_probe_ping` returned "disabled in this session" and
the MCP server never received `tools/call`; `inspect` on
`mcp-server:acppassed` returned `unknown capability_id`. With this change the
same probe returns the tool result and the server receives `tools/call`.

Both new boot tests were mutation-checked: reverting only the boot.go change
fails TestBuildEnablesHostSessionMCPForCapabilityDispatch while
TestBuildLeavesUnknownMCPServerUndispatchable keeps passing, so the fix cannot
be satisfied by defaulting unknown servers to enabled.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(mcp): state host-session precedence and unify the unregistered message

Follow-up to review on the host-session MCP dispatch fix.

Extract the capability-runtime inventory merge into
mergeHostSessionCapabilitySpecs. The previous change appended host-session specs
after the config specs and relied on ConfigureServers building its registry with
next[name] = ... in slice order, so a name collision resolved to the
host-session spec by map overwrite, 700 lines from where it was decided.

That precedence is correct — it is the same choice the registration tier already
makes explicitly, filtering colliding config specs out of configSpecs before
registerEnabledMCP — but it should be stated, not inherited from slice order.
Reorder the append or switch ConfigureServers to first-wins and the behaviour
silently inverts into the worst case: a user-disabled config spec marked enabled,
with an identity that no longer matches the tools pluginHost registered.

The merge also drops the shadowed config *entry*. ConfigureServers pairs an entry
with a spec by name, so a surviving config entry paired auto_start = false with
the live host-session spec, and /mcp reported one server as simultaneously
not-auto-start and not-disabled.

Unify the wording for "this session has no such MCP server". After the previous
change the same state had two spellings in one file: the three use_capability
sites said "is not registered in this session" while lockAuthorizedRuntimeServer
— the path a registry-resident mcp__server__tool takes — still said "is not
configured". Both are reachable. mcpServerUnregisteredMessage and
mcpServerDisabledMessage now hold the two spellings that are meant to differ.

Tests:

- TestMergeHostSessionCapabilitySpecsPrefersHostSessionOnNameCollision pins the
  precedence rule and that the shadowed entry is dropped. Asserted on the merge
  directly rather than through a booted Controller, since observing which spec
  reached the registry would otherwise need a production accessor existing only
  for the test.
- TestMergeHostSessionCapabilitySpecsIsIdentityWithoutHostSession pins that the
  ordinary config path is handed through untouched.
- TestBuildLeavesDisabledConfigMCPUndispatchableAlongsideHostSession pins that a
  config server with auto_start = false stays undispatchable while an unrelated
  host-session server is enabled in the same boot — the invariant the enablement
  loop could break by widening from "every extraSpec name" to "every name".

Both new behavioural tests were mutation-checked: replacing the merge with a
naive append fails the collision test, and widening the enablement loop to every
config name fails the disabled-config test.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* docs(boot): restore applyUnifiedProviderToolSurface's doc comment

mergeHostSessionCapabilitySpecs was inserted between
applyUnifiedProviderToolSurface's doc comment and its declaration, so the new
function carried a paragraph describing a different function and
applyUnifiedProviderToolSurface was left undocumented.

Move the helper and its comment below applyUnifiedProviderToolSurface. Both
identifiers are unexported, so gofmt, go vet and staticcheck's comment rules all
stay silent on this — only a human reading the file top to bottom sees it.

Pure move: 19 lines out, the same 19 in, no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(desktop): support macOS updates on exFAT

Use a macOS-updater-only rename fallback for filesystems without
RENAME_EXCL and compare copied app payloads without POSIX mode or
matching AppleDouble noise. Keep strict backup and staging identities
while accepting legacy strict handoff digests.

* fix(desktop): clarify exFAT rename fallback semantics

Problem: filesystems without RENAME_EXCL cannot provide an atomic no-replace rename through the fallback path.

Root cause: the fallback used an existence check followed by os.Rename without documenting the Reasonix lock boundary or surfacing its best-effort errors.

Fix: document the cooperative mutation-lock boundary and wrap fallback conflicts and rename failures with explicit best-effort context while preserving os.ErrExist matching.

Verification: go test ./internal/repair -count=1 -timeout=180s; cd desktop && go test . -run 'TestMacUpdateRename' -count=1 -timeout=180s

* fix(agent): rebuild zero-filled corrupt branch meta instead of failing saves / 修复:全零损坏的会话 meta 自动重建,不再阻断保存/关闭 (#9155)

* fix(agent): rebuild zero-filled corrupt branch meta instead of failing saves

A branch-meta sidecar can be torn into all-NUL (or all-whitespace) bytes when a
Windows non-atomic ReplaceFile in-place copy is interrupted by a forced reboot
(DPC_WATCHDOG_VIOLATION / power loss) — see #6325. Every save/close then fails
with 'decode branch meta: invalid character \x00' and the tab cannot be closed,
because LoadBranchMeta surfaces the decode error and callers (save.go:807,
ensureBranchMetaUnlocked) have no rebuild fallback.

Treat all-zero / all-whitespace undecodable meta as absent so it is rebuilt on
the next save, instead of blocking saves/closes. Genuine partial JSON (non-zero)
still returns an error so real corruption is not silently swallowed.

Tests: zero-filled -> absent, whitespace-only -> absent, partial JSON -> error,
EnsureBranchMeta rebuilds a corrupt sidecar. Refs #6325.

* docs(agent): clarify corrupt meta detection comment

Problem: the helper comment implied valid JSON cannot begin with whitespace.
Root cause: the wording conflated a JSON prefix with a document consisting only of JSON whitespace.
Fix: describe the actual all-NUL/all-whitespace invariant used by the detector.
Verification: git diff --check; internal/agent tests will run after updating the branch base.

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>

* docs(release): prepare v1.31.4 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.

* chore: ratchet repolint budget for compact ratio PR

Problem: the synchronized PR head exceeded existing repolint file-size and test-file-size budgets by small, intentional deltas in touched files.

Root cause: the latest main-v2 baseline predated this PR's compact-ratio validation, UI, CLI, and regression-test additions.

Fix: update only the six measured baseline entries required by the PR; no broad slack or unrelated baseline changes.

Verification: go run ./tools/repolint; go test ./internal/config ./internal/cli -count=1; git diff --check.

Co-authored-by: SivanCola <[email protected]>

* Make remote onboarding independently shippable

Problem:
- The wizard finished through remote-tab bindings that are introduced only by a later stacked PR.
- Browser file inputs cannot provide the absolute SSH identity path the desktop process needs.
- Modal focus, retry concurrency, and directory-response ordering were not protected.

Root cause:
- Browser mocks hid the Wails/native boundary and the stack assumed the later project-tab layer was already present.
- Connection retry and SFTP listing work had no single-flight or request-generation ownership.
- Wizard styles lived in the app-shell stylesheet.

Fix:
- Finish through the existing OpenRemoteWorkspace surface and remove later-layer API/config additions.
- Add a native identity-file picker and an early Linux/macOS platform probe.
- Trap and restore modal focus, hold retry busy state, and ignore stale directory responses.
- Extract growing modules, lazy-load wizard CSS, tighten bundle gates, and document the flow.

Verification:
- go build ./... && go vet ./... && go test ./...
- cd desktop && go build ./... && go test ./...
- go test -race ./internal/config
- cd desktop && go test -race . -run 'Test(PickRemoteIdentityFile|CheckRemotePlatform|Remote)'
- pnpm test:remote
- pnpm build
- REASONIX_CHANNEL=test pnpm build
- go run ./tools/repolint
- 1024x600 browser geometry, overflow, keyboard focus, and lazy CSS validation

Co-authored-by: SivanCola <[email protected]>

* fix(windows): use the correct small icon selector

Problem:
The window icon repair sent WM_SETICON with selector 2, which is only valid as ICON_SMALL2 for WM_GETICON. The per-window small icon therefore remained unset.

Root cause:
The getter-only small-icon selector was confused with WM_SETICON's ICON_SMALL value.

Fix:
Use selector 0 for the small window icon, expose narrow Win32 call seams so tests assert the exact selectors and class assignments, strengthen the no-icon guard, and document the bounded process-lifetime handle retention accurately.

Verification:
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go vet .
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c .
- go test ./...
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* Fix documented memory capability routes

Problem: The model-facing description advertised an unregistered memory:recall route, and the cache-prefix golden did not record the intended schema change.

Root cause: The prose was not checked against registry routing, while only the serialized request/schema goldens were refreshed.

Fix: Document the registered tool:memory recall route, tighten pinned guidance, add a routing regression test, and regenerate all cache-prefix goldens.

Verification: go test ./internal/agent ./internal/boot; go vet ./internal/agent ./internal/boot; go run ./tools/repolint; go build ./cmd/reasonix; go build ./internal/agent/... ./internal/capability/...

Co-authored-by: SivanCola <[email protected]>

* fix(desktop): isolate per-session archive actions

Problem:
- Grouped session rows shared topic-scoped menu and confirmation state.
- Successful session deletion refreshed only project shells, so the archived row could remain visible.
- The interrupted-job filters in the PR targeted a running-only API and could never observe interrupted tombstones.

Root cause:
- Session actions were keyed by topicId instead of exact row/session identity.
- The session archive path did not reload its owning canonical topic page.
- jobs.Manager.RunningForSession deliberately keeps unwinding work operationally running while excluding closed tombstones.

Fix:
- Key menus by row and confirmations by scoped topic/session identity.
- Serialize session archive actions, expose per-session busy state, and reload the owning project page.
- Preserve the existing running-job accounting contract and add focused regression coverage.
- Extract the session archive menu to keep ProjectTree within its repository budget.

Verification:
- pnpm build
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/project-tree-archive-race.test.ts
- go test -count=1 ./internal/jobs ./internal/control
- cd desktop && go test ./...
- go vet ./...
- cd desktop && go vet ./...
- go run ./tools/repolint

Co-authored-by: Linearleaf <[email protected]>

* Refactor host-session MCP dispatch ownership

Problem: The functional fix grew already-large agent, boot, and controller owner files past their repolint ratchets, and its tests asserted a production-only boolean accessor instead of exercising the real proxy route.

Root cause: Availability helpers and inventory merge policy lived in aggregate files, while the regression test stopped at the dispatch gate rather than crossing use_capability into tools/call.

Fix: Move MCP availability and host-session inventory logic into focused owner files, remove the Controller accessor, and drive the Streamable HTTP stub through an actual use_capability call while checking disabled and unknown refusals and provider-schema containment.

Verification: go run ./tools/repolint; go test ./...; focused go test -race for internal/boot, internal/agent, and internal/control; go vet for the owning packages; gofmt and git diff --check.

* fix(remote): harden project groups and credential proxy

Problem:
The project-groups stage exposed unfinished remote-tab actions and could lose per-workspace credential routes or provider endpoints after reconnects. Proxy forwarding, request-body handling, remote path resolution, and stale UI fetches also had security and race gaps.

Root cause:
The partial implementation mixed future tab plumbing with the current remote-window surface, used host-scoped assumptions for workspace state, and mutated shared proxy/server state without complete lifecycle fences.

Fix:
Keep the UI on the supported remote-window flow, persist and render project registrations safely, isolate workspace provider/token identities, heal all tracked routes and managed provider blocks on reconnect, harden the credential proxy and SFTP bootstrap, and split oversized modules without widening repolint baselines. Add focused frontend, Go, race, security, reconnect, and multi-workspace coverage.

Verification:
- pnpm build and pnpm test:remote
- Playwright motion and transcript browser gates
- go test ./... in the root and desktop modules
- CI concurrency-package and desktop full race suites
- Windows cross-compilation for changed root packages and desktop
- go vet, golangci-lint v2.12.2, and repolint

Co-authored-by: XTLine <[email protected]>

* feat(release-notes): split updates by client surface

Problem:
Release notes combine Desktop and CLI changes, so users cannot tell which client needs an update.

Root cause:
The release ledger has no item-level target metadata, and every renderer flattens the same product-wide sections.

Fix:
- add versioned Desktop, CLI, site, and service targets with strict validation
- derive target hints from pull request labels and changed paths
- render client-specific sections across GitHub notes, the website, and built-in docs
- preserve the legacy layout for historical releases

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- go test ./internal/productdocs
- cd site && npm test
- go test ./...
- cd site && npm run build
- bash scripts/release-workflows.test.sh
- node scripts/check-single-release-public-contract.mjs
- actionlint release workflows
- go run ./tools/repolint

Refs #9394

* test(desktop): synchronize quality floor fixture

Problem:
The desktop full race suite could fail in TestMetaReportsGoalStatus while an asynchronous tab metadata refresh read the quality floor.

Root cause:
The test directly mutated WorkspaceTab.qualityFloor without App.mu after MetaForTab had scheduled a background refresh. Production writes already use the app lock, but the fixture bypassed that contract.

Fix:
Guard the legacy empty-floor fixture mutation with App.mu so the test follows the same synchronization boundary as production code.

Verification:
- go test -race . -run TestMetaReportsGoalStatus -count=50

* fix(release-notes): require target metadata for future releases

Problem:
The legacy compatibility path could let manually authored v1.31.4 and newer records bypass per-client target metadata.

Root cause:
Validation required item targets only when targetingVersion was already present, without an adoption boundary for future releases.

Fix:
- require targetingVersion 1 for v1.31.4 and newer core versions
- preserve legacy rendering and validation for older releases
- cover the adoption boundary across stable and preview versions

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- bash scripts/release-workflows.test.sh
- node scripts/check-single-release-public-contract.mjs
- git diff --check

* fix(productdocs): use slices for release targets

Problem:
The CI lint job rejected the manual target lookup loop under the Go 1.26 modernize rules.

Root cause:
releaseItemHasTarget duplicated the standard slices.Contains operation.

Fix:
Use slices.Contains for the same exact membership check without changing release rendering behavior.

Verification:
- go test ./internal/productdocs
- go test ./...
- golangci-lint run --timeout=5m --allow-parallel-runners
- go run ./tools/repolint
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- git diff --check

* fix(agent): preserve memory activation semantics

Problem: The provider-visible memory guidance could make an update pass activation="relevant" and silently unpin an existing pinned memory, while forget and recall omitted their minimum arguments.

Root cause: The compact use_capability description conflated create and update defaults instead of matching the underlying memory schemas.

Fix: Document create/update activation behavior, the pinned opt-in, forget(name), and recall operations; enforce the contract with focused assertions; regenerate all cache-prefix goldens.

Verification: go test ./...; go test -race ./internal/agent -run TestUseCapabilityMemoryDescriptionNamesRoutableTools -count=1; go vet ./internal/agent ./internal/boot; go run ./tools/repolint; go build ./cmd/reasonix; go build ./internal/agent/... ./internal/capability/...

Co-authored-by: SivanCola <[email protected]>

* fix(remote): keep credential context out of logs

Problem:
CodeQL reported a high-severity clear-text logging path from provider credential configuration into the legacy Serve restart failure log.

Root cause:
The asynchronous fallback logged the complete error returned by EnsureServer. That error chain can include credential-configuration context and should not be persisted even though current messages avoid key values.

Fix:
Log only the safe host and workspace correlation fields for this restart failure. The detailed failure remains available through the managed Serve state without entering process logs.

Verification:
- go test ./... in desktop
- targeted remote desktop race suite
- golangci-lint v2.12.2
- repolint and git diff --check

* fix(desktop): keep archive failures visible

Problem:
- Active runtime-session rows could invoke DeleteSession and silently stop ongoing work.
- Topic tombstones were installed before TrashTopic committed, so refreshes could hide a topic even when the backend later rejected the archive.

Root cause:
- The per-session menu ignored the row's existing active-work archive guard.
- Pending UI state and the post-commit stale-response fence shared one lifecycle.

Fix:
- Disable per-session archive actions for thinking, streaming, confirmation, and background-job states.
- Commit topic tombstones only after backend success, fence pre-commit page loads at that boundary, and keep failure recovery visible.
- Extend the deterministic archive interleaving regression coverage.

Verification:
- pnpm exec tsx src/__tests__/project-tree-archive-race.test.ts
- pnpm exec tsx src/__tests__/project-tree-runtime.test.ts
- pnpm test:typecheck
- pnpm build
- pnpm test:remote
- cd desktop && go test -count=1 -run 'TestDeleteSessionCancelsActiveRuntime|TestDeleteSessionWithStuckJobReturnsAfterSingleGrace|TestTrashTopicRejectsRunningSessionRuntime|TestTrashTopicRejectsConcurrentTurnAdmissionWithoutWaiting' .
- go run ./tools/repolint
- git diff --check

Co-authored-by: Linearleaf <[email protected]>

* fix(serve): register remote model switch routes

Problem: remote session model and effort changes received HTTP 405 because the JSON endpoints were never registered.\n\nRoot cause: the branch added switch implementations and desktop callers but only kept the legacy slash-command interception inside POST /submit.\n\nFix: register POST /model and POST /effort, validate their JSON payloads, and distinguish busy transition conflicts from internal failures.\n\nVerification: go test ./internal/serve; targeted go test -race ./internal/serve; repository lint.

* fix(desktop): make remote tab activation transactional

Problem: switching from a local session to a remote tab could hide the local surface even when its current session failed to persist. Opening a remote project also mutated remote state before that safety barrier.

Root cause: remote activation bypassed the snapshot-before-switch contract used by local tabs, and local activation cleared the remote selection before the snapshot completed.

Fix: serialize the user-facing cross-registry activation paths, snapshot the current local session before remote activation, keep both registries unchanged on failure, and extract the selector from the oversized tabs file.

Verification: targeted desktop activation tests; targeted race tests; repolint; git diff --check.

* fix(frontend): preserve remote event mock fallback

Problem: browser tests and non-Wails previews can expose a partial runtime object without the generated Go application binding.

Root cause: the extracted remote event bridge treated window.runtime alone as proof that the native event API was usable.

Fix: require both the generated App binding and the Wails runtime before using native events, matching the original bridge guard and retaining the mock listener fallback.

Verification: frontend typecheck; remote frontend test suite.

* fix(desktop): enforce opaque operational theme surfaces

Problem:
Operational popovers such as Background jobs could become fully transparent, while undefined legacy theme variables also removed muted text, hover, border, and shadow styling.

Root cause:
Amber did not inherit a complete semantic surface contract, and operational overlays consumed translucent panel recipes without distinguishing ordinary themes from background-image theme packs.

Fix:
Define and enforce the semantic token contract, replace undefined legacy aliases, derive one overlay surface recipe from theme-pack pane opacity, and cover built-in themes plus portal overlays in a real browser. Keep Remote Connect Wizard CSS in its lazy production entry and align async test fixtures with the current HistorySlice contract.

Capacity:
The raw initial asset budget rises by 1.3 KiB (0.055%) for 1.09 KiB of attributable CSS; the existing gzip CSS ceiling remains unchanged.

Verification:
- pnpm check:css
- pnpm test:theme-browser
- pnpm test
- pnpm build
- git diff --check

* test(frontend): follow windowed history contract

Problem: the Goal activation routing test reverted its optimistic tab switch because the test harness could no longer load conversation history.

Root cause: after syncing main-v2, useController hydrates through HistorySliceForTab while this PR-owned harness still mocked only the retired page API.

Fix: provide the shared windowed-history mock with the current request type.

Verification: isolated routing test passed five consecutive runs; frontend typecheck.

* test(frontend): track hydration-aware tail guard

Problem: the full frontend suite failed its footer-resize source contract after syncing current main-v2.

Root cause: production correctly suppresses tail following during history hydration, but the static contract still expected the pre-hydration guard and dependency list.

Fix: assert the current hydration-aware guard while preserving the original coalesced tail-follow requirement.

Verification: history load failure contract; Goal activation routing test; frontend typecheck.

* test(frontend): isolate remote suites in their loaders

Problem: the generic frontend runner re-executed remote suites that already belong to test:remote, and the connect wizard failed before assertions because plain Node cannot import its CSS module.

Root cause: the PR added the suites to the dedicated script without registering all of them in OWNED_ELSEWHERE.

Fix: assign every dedicated remote suite to test:remote, retaining its CSS and SVG loader contracts and avoiding duplicate coverage.

Verification: run-tests syntax check; full test:remote suite.

* test(frontend): follow recommended preset order

Problem:\nThe startup settings contract still required DeepSeek Responses to be the first browser mock rank even after main added the recommended OpenCode Go bundle ahead of it.\n\nRoot cause:\nThe source-shape assertion encoded the old first-rule layout instead of the current two-entry ordering contract.\n\nFix:\nAssert that the recommended bundle ranks first and DeepSeek Responses remains immediately behind it.\n\nVerification:\n- pnpm exec tsx src/__tests__/startup-settings-contract.test.ts\n- git diff --check

* fix(desktop): preserve overlay opacity across theme menus

Route the higher-specificity styled-theme menu rule through the shared operational overlay surface. Expand the browser contract from the jobs popover to every representative operational surface so future selector-specific overrides cannot silently bypass paneOpacity.

* test(frontend): preserve deferred history race

Problem:\nThe tab-switch hydration suite awaited switchTab before releasing the intentionally deferred target history, leaving the module with an unsettled top-level await.\n\nRoot cause:\nswitchTab now correctly resolves only after target history hydration, while the stale-history race fixture still used the older activation-only completion order.\n\nFix:\nWait for backend activation and the scoped new-session action first, switch back to the cached source tab, then release history and await the original switch promise.\n\nVerification:\n- pnpm exec tsx src/__tests__/tab-switch-hydration.test.tsx\n- git diff --check

* test(frontend): follow transactional switch rollback

Problem:\nThe session-rebind suite awaited a tab switch while its target history was intentionally deferred, and still expected the failed target to remain visible after the controller adopted transactional rollback.\n\nRoot cause:\nThe fixture predated switchTab's hydrate-before-complete contract and its safe rebind to the retained source session on history failure.\n\nFix:\nObserve the optimistic target while the switch promise is pending, release the failed history, assert backend and transcript rollback, then retry generation two before exercising the same-tab generation fence.\n\nVerification:\n- pnpm exec tsx src/__tests__/tab-switch-session-rebind.test.tsx\n- git diff --check

* Migrate crash samples to Firebase Spark storage

Problem:
Crash samples remain in D1 indefinitely and repeated automatic crashes can upload without a version-scoped client boundary.

Root cause:
The ingest protocol has no stable event identity or local deduplication ledger, and the Worker has no bounded, idempotent Firebase projection path.

Fix:
- add event IDs and version-scoped Desktop crash deduplication
- add a bounded D1 outbox, projection receipts, group leases, and OAuth-authenticated Realtime Database delivery
- preserve D1 aggregates with d1, dual, and firebase rollout modes plus additive migration tooling

Verification:
- npm run typecheck
- npm test
- go test ./...
- cd desktop && go test -race ./...
- go test -race ./internal/crashreport
- go run ./tools/repolint

* fix(desktop): satisfy cross-platform lint gates

Problem:\nThe exact-head CI failed desktop lint on Linux and macOS, plus the root cross-platform build-tag sweep.\n\nRoot cause:\nThe remote-session changes retained an unused pre-snapshot map value, used loops now covered by the slicescontains rule, and ignored an auxiliary preference write without marking it best-effort. Two inherited Unix tray cancellation tests also lacked cleanup on timeout paths.\n\nFix:\nNarrow the remote lookup, use slices.Contains, explicitly keep the last-workspace preference non-fatal after a successful attach, and defer both DBus context cancellations.\n\nVerification:\n- golangci-lint run --timeout=5m ./... (root and desktop)\n- GOOS=windows golangci-lint run --timeout=5m ./... (root and desktop)\n- cd desktop && go test ./...\n- cd desktop && go test -race ./ -run targeted remote/tab/DBus cancellation suite\n- git diff --check

* Fix crash pending schema compatibility

Problem:
Current schema-v3 Desktop diagnostics are retained as unknown instead of uploaded, queue pruning can delete newer-schema pending files, and test-only URL matching and goroutine patterns fail required CI gates.

Root cause:
The pending reader's supported version stopped at schema 2, queue pruning ignored schema ownership, and the tests used broad URL substring checks plus superseded concurrency idioms.

Fix:
- align the pending reader with current schema 3 producers
- preserve future-schema files while pruning only understood queue entries
- use exact OAuth URL checks and CI-compliant deterministic test helpers

Verification:
- focused Desktop pending compatibility tests
- npm run typecheck
- npm test
- golangci-lint run --timeout=5m --allow-parallel-runners
- go run ./tools/repolint
- go test ./...
- cd desktop && go test -race ./...
- go test -race ./internal/crashreport

* fix(desktop): redact remote reconnect errors

Problem: Remote serve reconnect failures could write provider credentials embedded in bootstrap errors to desktop logs.

Root cause: reattachRemoteTab logged the complete EnsureServer error even though remote process output can carry forwarded API keys.

Fix: retain structural reconnect diagnostics while omitting the error body, and add a regression test that injects a secret-bearing error and verifies it never reaches the log.

Verification: cd desktop && go test ./...; cd desktop && go test -race ./ -run TestRemoteTabReconnectDoesNotLogEnsureServerSecrets\|TestRemoteTabFollowsHostReconnect -count=1; cd desktop && golangci-lint run ./...; cd desktop && GOOS=windows golangci-lint run ./...; go run ./tools/repolint; git diff --check

* Refactor MCP sessions onto the official Go SDK

Problem:
Stateful Streamable HTTP servers could lose their pending session before Reasonix opened the listening stream, while the custom transport engine duplicated protocol correlation, cancellation, and recovery logic.

Root cause:
Reasonix initialized Streamable HTTP with POST requests only and maintained separate request loops for HTTP, SSE, and stdio. Session loss, concurrent rebuilds, pagination, cancellation, OAuth refresh, and cleanup therefore had inconsistent owners.

Fix:
Adopt modelcontextprotocol/go-sdk v1.7.0 for protocol sessions, add a generation-fenced session supervisor, preserve Reasonix transport security and stdio process isolation, and cover JetBrains activation, recovery, pagination, cancellation, OAuth, schema stability, and bounded close behavior.

Verification:
- go test ./internal/plugin -count=1
- go test -race ./internal/plugin -count=1
- go test ./...
- go vet ./internal/plugin

* Normalize MCP capability call arguments

Problem:
Models sometimes encode an MCP tool argument object as one JSON string, causing use_capability to fail before dispatch with a map unmarshal error.

Root cause:
The proxy forwarded arguments without normalizing the provider's single-layer string encoding, and missing arguments remained null instead of an empty MCP object.

Fix:
Normalize only mcp-tool call arguments before resolution and safety checks. Accept objects, one string-wrapped object, and empty/null values while rejecting arrays, scalars, malformed JSON, and nested strings.

Verification:
- go test ./internal/agent -count=1
- go test -race ./internal/agent/... -count=1
- git diff --check

* Expose MCP session diagnostics in local surfaces

Problem:
MCP failures appeared as generic connection errors, leaving users unable to distinguish listening setup, session expiry, authentication, reconnect progress, or terminal transport failure.

Root cause:
Runtime session state stayed inside the transport and the CLI and Wails contracts exposed only coarse server status.

Fix:
Add optional protocol, session-state, reconnect-count, and error-kind fields to local status surfaces, preserve old payload defaults, and render actionable local diagnostics without exposing session identifiers or credentials.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race ./...
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build

* Document supervised MCP session behavior

Problem:
The product specification still described Reasonix as owning MCP protocol framing and did not explain the new lifecycle, recovery, pagination, or redacted diagnostics contract.

Root cause:
The documentation reflected the removed custom JSON-RPC transport engine.

Fix:
Document the official SDK boundary, shared supervised sessions, Streamable HTTP listening and cleanup, pagination, notifications, replay policy, and backward-compatible local diagnostics in English and Chinese, with an Unreleased changelog entry.

Verification:
- go test ./...
- cd desktop && go test ./...
- git diff --check

* fix(desktop): harden remote session lifecycle

Problem:
Remote tabs could lose prompts or publish ready across dead streams, and remote UI controls did not preserve local lifecycle contracts.

Root cause:
Serve attachment, hydration, reconnect, and remote composer state were coordinated by independent optimistic paths without one generation/prompt fence.

Fix:
Fence event streams and reattach generations, replay pending prompts, preserve remote session/mode/title/tab identity contracts, and add regression coverage.

Verification:
- go test ./...
- cd desktop && go test ./...
- cd desktop && go test -race . -run 'TestRemoteTab|TestCloseActiveRemoteTab|TestStopRemoteServer' -count=1\n- cd desktop/frontend && pnpm test:all\n- cd desktop/frontend && pnpm build\n- go run ./tools/repolint

* fix(desktop): satisfy remote tab lint contracts

Problem:
The macOS desktop lint job rejected two new remote-tab constructs under the repository lint profile.

Root cause:
The reconnect loop used a classic integer loop and event dispatch used an if chain instead of the preferred tagged switch.

Fix:
Use Go integer range and a kind switch without changing lifecycle behavior.

Verification:
- cd desktop && golangci-lint run --timeout=5m
- cd desktop && go test ./...
- go run ./tools/repolint
- git diff --check

* fix(crash): bound Firebase Spark storage lifecycle

Problem: Firebase-primary crash storage had no code-level Spark capacity budget or lifecycle state, and stale group writers could race retention or administrative deletion.

Root cause: delivery used a compatibility lease row and whole-group REST updates without durable generations, per-path preconditions, or conservative reservations.

Fix: add additive group state, a fixed 700 MiB reservation budget, generation leases, ETag-fenced path writes, 30/60-day lifecycle processing, delayed tombstone deletion, dashboard state, and sentinel alerts while keeping d1 independent and the production default.

Verification: npm run typecheck; npm test; npx wrangler deploy --dry-run; go run ./tools/repolint; git diff --check.

* fix(crash): verify paged Firebase migration

Problem: the retained-sample migration loaded all history at once, had no durable resume point, and treated HTTP success as content verification.

Root cause: migration state, lifecycle classification, canonical digests, and Firebase readback were not modeled as an idempotent reconciliation process.

Fix: add 200-group keyset pages, a mode-0600 atomic checkpoint, dry-run capacity/content estimates, three-pass reconciliation, canonical SHA-256 readback, D1 state verification, lifecycle-aware imports, and bilingual rollout/rollback instructions.

Verification: npm run typecheck; npm test; git diff --check; migration unit tests cover active/compacted/archived classification, absolute ring counts, and canonical digests.

* test(desktop): close DBus cancellation contexts

Problem: two DBus cancellation tests leaked their contexts when a timeout or t.Fatal path exited before the explicit cancel call.

Root cause: cleanup depended only on the success-path cancellation point.

Fix: defer both cancel functions immediately after context creation while retaining the explicit cancellation that drives each test.

Verification: go test ./... in desktop; go test -race . -run 'Test(WritePending|FlushPending|ConcurrentFlush)' -count=1; go vet ./...; golangci-lint run --timeout=5m ./....

* Split MCP lifecycle code along owner boundaries

Problem:
The completed MCP session migration exceeded repository capacity limits in several long-lived owner files and tests.

Root cause:
SDK adapters, session diagnostics, argument normalization, and deterministic fixtures were initially colocated with broader product surfaces.

Fix:
Extract the new responsibilities into focused owner files, group capability and auxiliary refresh state by lifetime, and keep all runtime behavior and public contracts unchanged.

Verification:
go run ./tools/repolint
go test ./...
go test -race ./internal/agent/... ./internal/plugin/... ./internal/boot/...
cd desktop && go test -race ./...
pnpm --dir desktop/frontend test:all
pnpm --dir desktop/frontend build

* Harden MCP session failure and shutdown boundaries

Problem:
Sessionless HTTP 404 responses were treated as expired sessions, and a non-returning HTTP tool request could outlive bounded session shutdown.

Root cause:
The SDK uses ErrSessionMissing for every HTTP 404, while the supervisor did not require an established session ID. The SDK also detaches Streamable HTTP from the Connect context and waits for active calls before closing, so CloseIdleConnections could not force active requests to unwind.

Fix:
Classify a 404 without a session ID as a protocol error with no automatic rebuild. Bind active GET and POST requests to the Reasonix session lifetime, preserve the bounded DELETE cleanup path, and forward idle-connection cleanup through the protected round tripper.

Verification:
Added deterministic sessionless-404 and active-request shutdown regressions. Ran focused normal and race tests, the JetBrains standalone GET/DELETE regression, repolint, and diff checks.

* Fix MCP CI portability regressions

Problem:
The first PR matrix exposed lint regressions and an intermittent macOS startup diagnostic that could miss a short-lived stdio server's stderr.

Root cause:
Removed transport interfaces left dead test helpers, the roots compatibility path needed an explicit deprecation waiver, and startup read the stderr tail before bounded process shutdown had drained the copy goroutine.

Fix:
Remove obsolete helpers, satisfy the repository's current lint rules while documenting the legacy roots requirement, and close/reap a failed endpoint before capturing its stderr tail.

Verification:
- golangci-lint run --timeout=5m
- go test ./internal/plugin -run '^TestStdioFailureCapturesStderr$' -count=20
- go test -race ./internal/plugin -run 'Test(StdioFailureCapturesStderr|HTTPTransportCloseCancelsActiveRequest|HTTPTransportEstablishedSessionless404DoesNotRebuild)$' -count=1
- go test ./internal/plugin ./internal/agent -count=1
- go vet ./internal/plugin ./internal/agent
- go run ./tools/repolint

* fix(desktop): complete remote session controls

* fix(serve): canonicalize remote model selections

* fix(crash): reconcile partial diagnostics schema

Problem: Crash worker deployments have been blocked since diagnostics-v2 left both metric-user tables without their attribution columns.

Root cause: The migration correctly failed closed for every partial schema, but had no bounded recovery path for the one verified production state or an interrupted recovery.

Fix: Recognize only the known 24-column drift and its retry subsets, record a fresh Time Travel bookmark, apply each additive column independently, verify the final schema, and keep unknown partial states fail closed.

Verification: npm run typecheck; npm test (109 tests); go run ./tools/repolint; git diff --check.

* fix(crash): stop requiring retired metric tables

Problem: The crash-worker deploy still fails before Firebase migration because diagnostics-v2 attempts to alter metric_users, which production intentionally dropped.

Root cause: The schema inspector treated the retired metric_users and cli_metric_users tables as required, so their absence looked like 24 missing columns.

Fix: Remove both retired tables from the diagnostics contract and additive migration, delete the invalid reconciliation path, preserve fail-closed handling for active schema drift, and document the retirement.

Verification: npm run typecheck; npm test (107 tests); go run ./tools/repolint; git diff --check.

* fix(desktop): harden remote session lifecycle

* fix(desktop): complete remote runtime parity

* fix(desktop): close remote session review gaps

* fix(desktop): close remaining remote surface gaps

* fix(ci): clear remote surface approval gates

* chore(deps): bump github.com/gorilla/websocket to 1.5.3 (#9382)

Update github.com/gorilla/websocket from v1.5.0 to v1.5.3 to fix GO-2026-6278.

* ci(crash): gate Firebase history migration (#9418)

Add a canary-approved Actions path for Firebase crash history dry-run, apply, and verify-only operations. Keep data migration separate from Worker deployment, require the existing Cloudflare and Firebase secrets, and preserve the production d1 storage mode until an explicit operator dispatch.

* fix(crash): bound Firebase migration output capture (#9424)

Problem:
The protected Firebase history dry-run failed before capacity analysis because Wrangler's retained-report JSON exceeded Node's default synchronous child-process buffer.

Root cause:
The migration captured a page of up to 200 groups and six 96 KiB retained reports per group without setting spawnSync.maxBuffer.

Fix:
Use a 192 MiB hard limit that covers the 112.5 MiB raw page plus JSON envelope and escaping headroom, and lock the bound into the migration contract test and declaration file.

Verification:
- npm run typecheck
- npm test (13 files, 111 tests)
- go run ./tools/repolint
- git diff --check

* fix(catalog): publish recovery projections atomically / 原子发布恢复会话投影 (#9421)

* fix(catalog): publish recovery projections atomically

Problem: exact path refreshes and batched directory scans could expose a temporary recovery-session projection, making sidebar rows disappear and return.

Root cause: single-file indexing owned sibling-derived recovery fields, while reconcile committed each 64-row batch independently.

Fix: separate exact-source and directory-projection ownership, preserve derived SQL columns, queue dirty recovery directories, and publish each full directory projection in one transaction.

Verification: go test ./...; go test -race ./internal/sessioncatalog; desktop go test ./...; deterministic rollback/interleaving/65-row tests; 10k reconcile benchmark.

* Fix repair projection ownership

Problem: asynchronous count repair could rewrite recovery projection fields and publish a partial topic snapshot before directory reconciliation.

Root cause: repair updated recovery_copy and topic aggregates outside the directory lock, so it could interleave with a parsed reconcile snapshot.

Fix: serialize repair with the directory lock, update only source-owned fields, preserve repaired source state across legacy sidecars, and leave projection publication to one directory transaction. Extract upsert and repair ownership into focused files to satisfy repository size gates.

Verification: go test ./...; go test -race ./internal/sessioncatalog; go vet ./internal/sessioncatalog; go run ./tools/repolint; BenchmarkReconcileDirectory10K (3x, 2.74s/op).

* Remove superseded catalog projection paths

Problem: the atomic directory projection refactor left two unused pre-transaction helpers, causing golangci-lint to block PR #9421.\n\nRoot cause: ReconcileDirectory now owns the complete projection transaction, but the old partial lineage and no-notification upsert entry points remained compiled.\n\nFix: remove the unreachable helpers and imports so there is only one directory publication path.\n\nVerification: go test ./internal/sessioncatalog; go vet ./internal/sessioncatalog; git diff --check.

* fix(desktop): commit session navigation after target paint / 会话导航在目标绘制后提交 (#9422)

* fix(desktop): commit transcript navigation after paint

Problem: session navigation exposed empty/intermediate transcript states, changed viewport geometry, and could cascade older-history loads before the target surface settled.

Root cause: navigation settled on RPC completion, source state was pruned early, startReached had no user permit, and layout growth lacked mutation ownership.

Fix: retain and mask the source surface through data and paint readiness, defer single-surface pruning, restore failed sources, gate pagination by trigger, preserve composer geometry, and diagnose invalid ordering.

Verification: pnpm test:typecheck; pnpm test:transcript; all 225 frontend suites; single-scroll-writer check; deterministic A-B-C, failure restore, pagination, and paint terminal tests.

* fix(desktop): commit transcript navigation surfaces atomically

Problem: session switches could expose empty or unsettled target history, collapse footer geometry, and cascade older-history loads before user intent.

Root cause: navigation completion was tied to request return rather than authoritative history plus stable paint, while startReached had no single-use viewport permit and stale completions could clean up newer surfaces.

Fix: retain the source surface behind an opaque mask, gate commit on intent-scoped history and two stable geometry frames, restore the source on failures, authorize viewport paging per gesture, and classify history mutations and diagnostics.

Verification: pnpm test (225 suites), pnpm test:typecheck, pnpm build, pnpm test:transcript-browser, desktop go test ./..., single-scroll-writer check, repolint, and diff check.

* test(desktop): stabilize native thumb replay

* test(desktop): preserve native thumb ownership

* test(desktop): discover native thumb hitbox

* test(desktop): exercise native thumb in headed Linux

* fix(desktop): freeze measured row during thumb drag

* fix(crash): explain Firebase capacity reservations (#9425)

Problem:
The protected migration dry-run reported 948 active groups and 84.6% reserved capacity without explaining which lifecycle rules kept those groups active.

Root cause:
The dry-run only aggregated final active, compacted, and archived classifications, so operators could not distinguish recent resolved groups from stale open groups that require manual triage.

Fix:
Add a count-only status and last-seen assessment, cross-check it against lifecycle classification, report manual-review candidates and theoretical reservation reduction, and emit the 80% capacity warning. Fingerprints and crash fields are intentionally excluded.

Verification:
- npm run typecheck
- npm test (13 files, 112 tests)
- go run ./tools/repolint
- git diff --check

* feat(runtime): make turns durable, ordered, and interrupt-safe (#9409)

* feat(runtime): add structured turn protocol and ledger

Problem: Turn completion was inferred from provider text, so EOF, interruption, and protocol failures could collapse into the same successful outcome without a durable lifecycle record.

Root cause: The transcript doubled as runtime truth and there was no stable turn identity, monotonic event envelope, exactly-once terminal commit, or explicit provider finalization contract.

Fix: Add the queued-to-terminal TurnStatus model, append-only .turns.jsonl ledger with torn-tail quarantine and terminal fsync, and a stable host-consumed finish tool with one bounded repair round.

Verification: Covered by focused agent, boot, control, event wire, store, and turnevent tests plus the root Go suite.

* feat(runtime): route cancellation and prompts by turn

Problem: Stop and prompt answers could be routed through whichever tab was currently active, allowing tab switches and overlapping completions to affect the wrong turn or leave work running.

Root cause: Desktop admission and Wails entry points did not carry a stable turn ID through start, interrupt, prompt answer, and replay operations.

Fix: Add turn-scoped StartTurnForTab, InterruptTurnForTab, AnswerPromptForTab, and TurnEventsForTab contracts; bind cancellation and prompt ownership to the exact session/turn; and keep one foreground turn per session while preserving cross-session concurrency.

Verification: Covered by deterministic control and desktop runtime tests, full root and desktop suites, and race tests for agent/control/turnevent.

* fix(frontend): project ordered turn events without rollback

Problem: A late cancellation reload could replace the full transcript and erase a newer retry, while asynchronous Markdown finalization could move a reader back toward the tail.

Root cause: The frontend reconciled cancellation with history replacement instead of an ordered turn projection, and a fresh history fallback could be swapped for a tail-only block window while the user was reading above the bottom.

Fix: Project events idempotently by runtime epoch and sequence, replay gaps from the ledger, reject stale revisions, patch only the affected turn, preserve pending-only height estimates, and defer the Markdown fallback-to-block swap until the reader returns to the bottom.

Verification: Covered by deterministic controller ordering tests, Markdown history handoff tests, the transcript suite, frontend typecheck/build budgets, and native Windows WebView2 Stop/Ask/parallel/scroll smoke.

Co-authored-by: Linearleaf <[email protected]>

* test(runtime): update compatibility and cache fixtures

Problem: The new lifecycle sidecar, Wails arrays, provider-visible finish schema, and Windows smoke cleanup needed explicit cross-version and deterministic serialization guards.

Root cause: Existing fixtures described the legacy tool prefix and did not exercise empty-array bridge encoding, old-session bootstrap, torn-tail recovery, or PowerShell 5.1 cleanup beyond MAX_PATH.

Fix: Update bilingual tool-contract documentation and golden provider fixtures, add compatibility/cache/recovery tests, and make the native WebView2 smoke cleanup use extended-length .NET paths with a deterministic long-path self-test.

Verification: go test ./..., go test -race ./internal/agent ./internal/control ./internal/turnevent, desktop go test ./..., frontend build and transcript tests, and Windows 11 ARM64 native smoke all pass.

* perf(runtime): bound turn ledger writes and retention

Problem: the turn ledger opened the sidecar for every event and retained an unbounded event history, while prompt and tool side effects could not observe persistence failures.

Root cause: stream coalescing happened downstream from persistence, the ledger had no persistent turn writer or checkpoint format, and prompt resolution removed waiters before durable acknowledgement.

Fix: move the single coalescer before the ledger, add checked side-effect barriers, reuse one WAL handle per turn, add schema-v2 checkpoints and projection acknowledgements, make Ask and approval resolution transactional, and expose content-free metrics and capacity benchmarks.

Verification: go test ./...; go test -race ./internal/agent ./internal/control ./internal/turnevent ./internal/event; cd desktop && go test ./...

* fix(frontend): recover compacted turn-event gaps

Problem: a frontend cursor that falls behind a compacted ledger cannot distinguish retention from packet loss, and a full transcript reload can erase an active or optimistic tail.

Root cause: the replay API returned only an array and the reducer had no explicit persisted-prefix boundary or runtime-epoch transaction.

Fix: return paged replay metadata with reset fencing, queue live events during repair, rebase only the persisted history prefix with stable identities, and retain active turns, optimistic users, and live assistant output.

Verification: frontend typecheck, pnpm test:transcript, replay-reset and cancel-reconcile tests, pnpm build, and desktop go test ./...

* chore(lint): refresh turn-runtime quality baselines

Problem: the repository lint gate still compared the expanded runtime ledger and replay owners against the pre-performance exact baselines.

Fix: tighten new comments to the essay limits and refresh the mechanical per-file and repository totals after the two functional commits. This changes no runtime behavior.

Verification: go run ./tools/repolint; go test ./internal/boot ./internal/control ./internal/turnevent; cd desktop && go test . -run TestTurnRuntimeAPIRoutesStopAnswerAndReplayByExactTurn -count=1

* fix(runtime): preserve ledger storage failures

* chore(lint): align baseline with main-v2

* chore(runtime): satisfy ledger lint contracts

* chore(lint): align merged transcript baseline

Problem: the merged tree exceeded the test-file baseline by one line.

Root cause: main-v2 added one navigation hydration assertion after the turn-runtime branch recorded its own test baseline.

Fix: carry the exact combined per-file and repository totals without changing production code.

Verification: go run ./tools/repolint reports a clean baseline.

---------

Co-authored-by: Linearleaf <[email protected]>

* fix(desktop): finish remote session approval parity

* refactor(remote): satisfy repository ownership gates

* fix(remote): close approval review gaps

* fix(remote): resolve final approval races

* fix(remote): close remaining session surface gaps

* fix(remote): close remaining session parity review gaps

* fix(frontend): retain generated remote binding parity

* fix(remote): recover remaining runtime parity gaps

* feat(remote): complete multi-session serve integration

Preserve active remote turns across session switches, route tagged events to the owning tab, and expose exact session paths to the desktop tree. Add gzip snapshot transport, capability-gated remote bootstrap, and credential-channel watchdog recovery. Update Remote SSH documentation and cover the integration with Go, race, and frontend tests.

* refactor(remote): satisfy repository structure gates

* fix(remote): close multi-session lifecycle races

* fix(remote): preserve foreground session routing

Problem:\nServe and Desktop could lose the foreground session identity when controllers detached, recovered, or rotated through slash commands. Replay and status requests could also mix controller generations, and background runtime work was underreported.\n\nRoot cause:\nController ownership callbacks were rebound to keeper-only handlers, status and replay sampled the mutable controller repeatedly, and the Desktop did not reconcile a recovered foreground path before routing tagged frames.\n\nFix:\nCarry the Serve ownership binder across keeper transfers, use single controller snapshots, refresh transition tags and broadcaster routing, expose the authoritative session path, and reconcile unknown foreground frame paths from status.\n\nVerification:\ngo run ./tools/repolint\ngo test ./...\ngo vet ./...\n(cd desktop && go test ./...)\n(cd desktop && go vet ./...)\ntargeted go test -race for control, Serve, and Desktop multi-session paths\ngit diff --check

* fix(remote): close final session routing gaps

* fix(remote): close remaining serve lifecycle races

* test(cli): close queued-paste controller

* fix(remote): close final multi-session routing gaps

* fix(remote): fence recovery lifecycle races

* fix(remote): close final multisession lifecycle gaps

* fix(remote): heal all tracked proxy workspaces

* fix(remote): harden multisession ownership handoff

* fix(remote): close remaining multisession races

* test(serve): make rotation lock probe observable

* fix(remote): close session handoff races

* test(remote): use test-scoped attach context

* fix(remote): isolate session adoption state

* fix(serve): preserve committed session routing

* test(serve): exercise real silent rotations

* fix(remote): preserve listing adoption and reload budgets

* test(remote): await emitted ready state

* fix(remote): fence session rehydration

* fix(remote): harden process and session handoff

* fix(remote): fence cross-session ownership

* fix(remote): preserve resume lifecycle events

* fix(remote): reconcile resume ownership and routing

* fix(remote): preserve lifecycle work under load

* fix(remote): commit session routing before replay

* fix(remote): serialize session route publication

* fix(remote): order status route adoption

* fix(remote): order resume reconciliation

* test(remote): assert adopted status route

* fix(remote): reject stale resume reconciliation

* fix(remote): fence foreground session mutations

* fix(remote): close remaining session transition races

* fix(remote): fence recovery and attach publication

* fix(remote): reconcile terminal routing races

* fix(remote): refresh recovered terminal listings

---------

Co-authored-by: Xingbo Zhao <[email protected]>
Co-authored-by: Linearleaf <[email protected]>
Co-authored-by: SivanCola <[email protected]>
Co-authored-by: hanswu <[email protected]>
Co-authored-by: SivanCola <[email protected]>
Co-authored-by: zhongerdeajie <[email protected]>
Co-authored-by: YHH <[email protected]>
Co-authored-by: yuhuping <[email protected]>
Co-authored-by: poorpaper <[email protected]>
Co-authored-by: SivanCola <[email protected]>
Co-authored-by: Hardik Shingala <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: zcxads666 <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Linearleaf <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-27 06:15:24 +08:00
SivanCola f61ce8de2e Merge pull request #9369 from XTLine/feat/remote-session-surface
feat(desktop): remote workspace onboarding — full-parity remote sessions / 远程工作区接入:全功能远程会话 [1/3]
2026-08-26 15:26:58 +08:00
SivanCola 48f93e359c fix(remote): recover remaining runtime parity gaps 2026-08-26 15:04:48 +08:00
SivanCola c84fba31db fix(frontend): retain generated remote binding parity 2026-08-26 14:20:41 +08:00
SivanCola 2c0b1b4ab9 fix(remote): close remaining session parity review gaps 2026-08-26 14:13:10 +08:00
SivanCola 03fd67607a fix(remote): close remaining session surface gaps 2026-08-26 13:33:43 +08:00
SivanCola 19a4231cc0 fix(remote): resolve final approval races 2026-08-26 13:04:01 +08:00
SivanCola 7aa2bf5331 fix(remote): close approval review gaps 2026-08-26 12:49:28 +08:00
SivanCola 0a79d79f09 refactor(remote): satisfy repository ownership gates 2026-08-26 11:58:32 +08:00
SivanCola 3e584e765c Merge remote-tracking branch 'origin/main-v2' into fix/pr-9369-approval
# Conflicts:
#	desktop/frontend/scripts/check-bundle-budget.mjs
#	desktop/frontend/src/App.tsx
#	desktop/frontend/src/components/Composer.tsx
#	desktop/frontend/src/lib/useController.ts
2026-08-26 11:30:40 +08:00
SivanCola e547a77a3f fix(desktop): finish remote session approval parity 2026-08-26 11:10:12 +08:00
SivanColaandLinearleaf c3f5b497d5 feat(runtime): make turns durable, ordered, and interrupt-safe (#9409)
* feat(runtime): add structured turn protocol and ledger

Problem: Turn completion was inferred from provider text, so EOF, interruption, and protocol failures could collapse into the same successful outcome without a durable lifecycle record.

Root cause: The transcript doubled as runtime truth and there was no stable turn identity, monotonic event envelope, exactly-once terminal commit, or explicit provider finalization contract.

Fix: Add the queued-to-terminal TurnStatus model, append-only .turns.jsonl ledger with torn-tail quarantine and terminal fsync, and a stable host-consumed finish tool with one bounded repair round.

Verification: Covered by focused agent, boot, control, event wire, store, and turnevent tests plus the root Go suite.

* feat(runtime): route cancellation and prompts by turn

Problem: Stop and prompt answers could be routed through whichever tab was currently active, allowing tab switches and overlapping completions to affect the wrong turn or leave work running.

Root cause: Desktop admission and Wails entry points did not carry a stable turn ID through start, interrupt, prompt answer, and replay operations.

Fix: Add turn-scoped StartTurnForTab, InterruptTurnForTab, AnswerPromptForTab, and TurnEventsForTab contracts; bind cancellation and prompt ownership to the exact session/turn; and keep one foreground turn per session while preserving cross-session concurrency.

Verification: Covered by deterministic control and desktop runtime tests, full root and desktop suites, and race tests for agent/control/turnevent.

* fix(frontend): project ordered turn events without rollback

Problem: A late cancellation reload could replace the full transcript and erase a newer retry, while asynchronous Markdown finalization could move a reader back toward the tail.

Root cause: The frontend reconciled cancellation with history replacement instead of an ordered turn projection, and a fresh history fallback could be swapped for a tail-only block window while the user was reading above the bottom.

Fix: Project events idempotently by runtime epoch and sequence, replay gaps from the ledger, reject stale revisions, patch only the affected turn, preserve pending-only height estimates, and defer the Markdown fallback-to-block swap until the reader returns to the bottom.

Verification: Covered by deterministic controller ordering tests, Markdown history handoff tests, the transcript suite, frontend typecheck/build budgets, and native Windows WebView2 Stop/Ask/parallel/scroll smoke.

Co-authored-by: Linearleaf <[email protected]>

* test(runtime): update compatibility and cache fixtures

Problem: The new lifecycle sidecar, Wails arrays, provider-visible finish schema, and Windows smoke cleanup needed explicit cross-version and deterministic serialization guards.

Root cause: Existing fixtures described the legacy tool prefix and did not exercise empty-array bridge encoding, old-session bootstrap, torn-tail recovery, or PowerShell 5.1 cleanup beyond MAX_PATH.

Fix: Update bilingual tool-contract documentation and golden provider fixtures, add compatibility/cache/recovery tests, and make the native WebView2 smoke cleanup use extended-length .NET paths with a deterministic long-path self-test.

Verification: go test ./..., go test -race ./internal/agent ./internal/control ./internal/turnevent, desktop go test ./..., frontend build and transcript tests, and Windows 11 ARM64 native smoke all pass.

* perf(runtime): bound turn ledger writes and retention

Problem: the turn ledger opened the sidecar for every event and retained an unbounded event history, while prompt and tool side effects could not observe persistence failures.

Root cause: stream coalescing happened downstream from persistence, the ledger had no persistent turn writer or checkpoint format, and prompt resolution removed waiters before durable acknowledgement.

Fix: move the single coalescer before the ledger, add checked side-effect barriers, reuse one WAL handle per turn, add schema-v2 checkpoints and projection acknowledgements, make Ask and approval resolution transactional, and expose content-free metrics and capacity benchmarks.

Verification: go test ./...; go test -race ./internal/agent ./internal/control ./internal/turnevent ./internal/event; cd desktop && go test ./...

* fix(frontend): recover compacted turn-event gaps

Problem: a frontend cursor that falls behind a compacted ledger cannot distinguish retention from packet loss, and a full transcript reload can erase an active or optimistic tail.

Root cause: the replay API returned only an array and the reducer had no explicit persisted-prefix boundary or runtime-epoch transaction.

Fix: return paged replay metadata with reset fencing, queue live events during repair, rebase only the persisted history prefix with stable identities, and retain active turns, optimistic users, and live assistant output.

Verification: frontend typecheck, pnpm test:transcript, replay-reset and cancel-reconcile tests, pnpm build, and desktop go test ./...

* chore(lint): refresh turn-runtime quality baselines

Problem: the repository lint gate still compared the expanded runtime ledger and replay owners against the pre-performance exact baselines.

Fix: tighten new comments to the essay limits and refresh the mechanical per-file and repository totals after the two functional commits. This changes no runtime behavior.

Verification: go run ./tools/repolint; go test ./internal/boot ./internal/control ./internal/turnevent; cd desktop && go test . -run TestTurnRuntimeAPIRoutesStopAnswerAndReplayByExactTurn -count=1

* fix(runtime): preserve ledger storage failures

* chore(lint): align baseline with main-v2

* chore(runtime): satisfy ledger lint contracts

* chore(lint): align merged transcript baseline

Problem: the merged tree exceeded the test-file baseline by one line.

Root cause: main-v2 added one navigation hydration assertion after the turn-runtime branch recorded its own test baseline.

Fix: carry the exact combined per-file and repository totals without changing production code.

Verification: go run ./tools/repolint reports a clean baseline.

---------

Co-authored-by: Linearleaf <[email protected]>
2026-08-26 09:19:12 +08:00
SivanCola ce96692e4c fix(crash): explain Firebase capacity reservations (#9425)
Problem:
The protected migration dry-run reported 948 active groups and 84.6% reserved capacity without explaining which lifecycle rules kept those groups active.

Root cause:
The dry-run only aggregated final active, compacted, and archived classifications, so operators could not distinguish recent resolved groups from stale open groups that require manual triage.

Fix:
Add a count-only status and last-seen assessment, cross-check it against lifecycle classification, report manual-review candidates and theoretical reservation reduction, and emit the 80% capacity warning. Fingerprints and crash fields are intentionally excluded.

Verification:
- npm run typecheck
- npm test (13 files, 112 tests)
- go run ./tools/repolint
- git diff --check
2026-08-26 05:01:32 +08:00
SivanCola 9f61201be7 fix(desktop): commit session navigation after target paint / 会话导航在目标绘制后提交 (#9422)
* fix(desktop): commit transcript navigation after paint

Problem: session navigation exposed empty/intermediate transcript states, changed viewport geometry, and could cascade older-history loads before the target surface settled.

Root cause: navigation settled on RPC completion, source state was pruned early, startReached had no user permit, and layout growth lacked mutation ownership.

Fix: retain and mask the source surface through data and paint readiness, defer single-surface pruning, restore failed sources, gate pagination by trigger, preserve composer geometry, and diagnose invalid ordering.

Verification: pnpm test:typecheck; pnpm test:transcript; all 225 frontend suites; single-scroll-writer check; deterministic A-B-C, failure restore, pagination, and paint terminal tests.

* fix(desktop): commit transcript navigation surfaces atomically

Problem: session switches could expose empty or unsettled target history, collapse footer geometry, and cascade older-history loads before user intent.

Root cause: navigation completion was tied to request return rather than authoritative history plus stable paint, while startReached had no single-use viewport permit and stale completions could clean up newer surfaces.

Fix: retain the source surface behind an opaque mask, gate commit on intent-scoped history and two stable geometry frames, restore the source on failures, authorize viewport paging per gesture, and classify history mutations and diagnostics.

Verification: pnpm test (225 suites), pnpm test:typecheck, pnpm build, pnpm test:transcript-browser, desktop go test ./..., single-scroll-writer check, repolint, and diff check.

* test(desktop): stabilize native thumb replay

* test(desktop): preserve native thumb ownership

* test(desktop): discover native thumb hitbox

* test(desktop): exercise native thumb in headed Linux

* fix(desktop): freeze measured row during thumb drag
2026-08-26 04:37:12 +08:00
SivanCola f731633314 fix(catalog): publish recovery projections atomically / 原子发布恢复会话投影 (#9421)
* fix(catalog): publish recovery projections atomically

Problem: exact path refreshes and batched directory scans could expose a temporary recovery-session projection, making sidebar rows disappear and return.

Root cause: single-file indexing owned sibling-derived recovery fields, while reconcile committed each 64-row batch independently.

Fix: separate exact-source and directory-projection ownership, preserve derived SQL columns, queue dirty recovery directories, and publish each full directory projection in one transaction.

Verification: go test ./...; go test -race ./internal/sessioncatalog; desktop go test ./...; deterministic rollback/interleaving/65-row tests; 10k reconcile benchmark.

* Fix repair projection ownership

Problem: asynchronous count repair could rewrite recovery projection fields and publish a partial topic snapshot before directory reconciliation.

Root cause: repair updated recovery_copy and topic aggregates outside the directory lock, so it could interleave with a parsed reconcile snapshot.

Fix: serialize repair with the directory lock, update only source-owned fields, preserve repaired source state across legacy sidecars, and leave projection publication to one directory transaction. Extract upsert and repair ownership into focused files to satisfy repository size gates.

Verification: go test ./...; go test -race ./internal/sessioncatalog; go vet ./internal/sessioncatalog; go run ./tools/repolint; BenchmarkReconcileDirectory10K (3x, 2.74s/op).

* Remove superseded catalog projection paths

Problem: the atomic directory projection refactor left two unused pre-transaction helpers, causing golangci-lint to block PR #9421.\n\nRoot cause: ReconcileDirectory now owns the complete projection transaction, but the old partial lineage and no-notification upsert entry points remained compiled.\n\nFix: remove the unreachable helpers and imports so there is only one directory publication path.\n\nVerification: go test ./internal/sessioncatalog; go vet ./internal/sessioncatalog; git diff --check.
2026-08-26 03:01:21 +08:00
SivanCola 5ed16fdefc fix(crash): bound Firebase migration output capture (#9424)
Problem:
The protected Firebase history dry-run failed before capacity analysis because Wrangler's retained-report JSON exceeded Node's default synchronous child-process buffer.

Root cause:
The migration captured a page of up to 200 groups and six 96 KiB retained reports per group without setting spawnSync.maxBuffer.

Fix:
Use a 192 MiB hard limit that covers the 112.5 MiB raw page plus JSON envelope and escaping headroom, and lock the bound into the migration contract test and declaration file.

Verification:
- npm run typecheck
- npm test (13 files, 111 tests)
- go run ./tools/repolint
- git diff --check
2026-08-26 02:40:37 +08:00
SivanCola b3941fef7c ci(crash): gate Firebase history migration (#9418)
Add a canary-approved Actions path for Firebase crash history dry-run, apply, and verify-only operations. Keep data migration separate from Worker deployment, require the existing Cloudflare and Firebase secrets, and preserve the production d1 storage mode until an explicit operator dispatch.
2026-08-26 01:28:35 +08:00
dependabot[bot] 9b9d7ae0fd chore(deps): bump github.com/gorilla/websocket to 1.5.3 (#9382)
Update github.com/gorilla/websocket from v1.5.0 to v1.5.3 to fix GO-2026-6278.
2026-08-26 00:50:59 +08:00
SivanCola 3e5a8f048b Merge remote-tracking branch 'origin/main-v2' into fix/pr-9369-approval
# Conflicts:
#	go.mod
2026-08-26 00:22:22 +08:00
SivanCola 1170cac985 fix(ci): clear remote surface approval gates 2026-08-26 00:15:27 +08:00
SivanCola 1dc3743303 fix(desktop): close remaining remote surface gaps 2026-08-26 00:05:24 +08:00
SivanCola 65525046ff fix(desktop): close remote session review gaps 2026-08-25 23:31:07 +08:00
SivanCola 6d34b84004 fix(desktop): complete remote runtime parity 2026-08-25 23:06:38 +08:00
SivanCola f272b05eb3 fix(desktop): harden remote session lifecycle 2026-08-25 22:36:19 +08:00
SivanCola 489201ba51 Merge pull request #9415 from SivanCola/fix/diagnostics-v2-retired-tables
Stop requiring retired crash metric tables / 停止要求已退休的崩溃指标表
2026-08-25 22:13:04 +08:00
SivanCola ed324f1ff2 fix(crash): stop requiring retired metric tables
Problem: The crash-worker deploy still fails before Firebase migration because diagnostics-v2 attempts to alter metric_users, which production intentionally dropped.

Root cause: The schema inspector treated the retired metric_users and cli_metric_users tables as required, so their absence looked like 24 missing columns.

Fix: Remove both retired tables from the diagnostics contract and additive migration, delete the invalid reconciliation path, preserve fail-closed handling for active schema drift, and document the retirement.

Verification: npm run typecheck; npm test (107 tests); go run ./tools/repolint; git diff --check.
2026-08-25 22:02:58 +08:00
SivanCola c8664fe5b9 Merge pull request #9413 from SivanCola/fix/diagnostics-v2-reconcile
Fix crash worker deployment after partial diagnostics migration
2026-08-25 21:45:23 +08:00
SivanCola f04296ff1a fix(crash): reconcile partial diagnostics schema
Problem: Crash worker deployments have been blocked since diagnostics-v2 left both metric-user tables without their attribution columns.

Root cause: The migration correctly failed closed for every partial schema, but had no bounded recovery path for the one verified production state or an interrupted recovery.

Fix: Recognize only the known 24-column drift and its retry subsets, record a fresh Time Travel bookmark, apply each additive column independently, verify the final schema, and keep unknown partial states fail closed.

Verification: npm run typecheck; npm test (109 tests); go run ./tools/repolint; git diff --check.
2026-08-25 21:29:30 +08:00
SivanCola 538f64e87e fix(serve): canonicalize remote model selections 2026-08-25 21:21:46 +08:00
SivanCola 8a0f628a7b Merge pull request #9410 from SivanCola/fix/mcp-sdk-session
Fix supervised MCP sessions with the official Go SDK / 使用官方 Go SDK 修复 MCP 会话
2026-08-25 21:16:39 +08:00
SivanCola c2552758d3 fix(desktop): complete remote session controls 2026-08-25 21:03:39 +08:00
SivanCola 2f7ad7de57 Merge pull request #9407 from SivanCola/feature/firebase-spark-crash
Migrate crash storage to Firebase Spark / 将崩溃存储迁移到 Firebase Spark
2026-08-25 20:56:30 +08:00
SivanCola 9f00da2001 Fix MCP CI portability regressions
Problem:
The first PR matrix exposed lint regressions and an intermittent macOS startup diagnostic that could miss a short-lived stdio server's stderr.

Root cause:
Removed transport interfaces left dead test helpers, the roots compatibility path needed an explicit deprecation waiver, and startup read the stderr tail before bounded process shutdown had drained the copy goroutine.

Fix:
Remove obsolete helpers, satisfy the repository's current lint rules while documenting the legacy roots requirement, and close/reap a failed endpoint before capturing its stderr tail.

Verification:
- golangci-lint run --timeout=5m
- go test ./internal/plugin -run '^TestStdioFailureCapturesStderr$' -count=20
- go test -race ./internal/plugin -run 'Test(StdioFailureCapturesStderr|HTTPTransportCloseCancelsActiveRequest|HTTPTransportEstablishedSessionless404DoesNotRebuild)$' -count=1
- go test ./internal/plugin ./internal/agent -count=1
- go vet ./internal/plugin ./internal/agent
- go run ./tools/repolint
2026-08-25 20:28:56 +08:00
SivanCola 44222afeba Harden MCP session failure and shutdown boundaries
Problem:
Sessionless HTTP 404 responses were treated as expired sessions, and a non-returning HTTP tool request could outlive bounded session shutdown.

Root cause:
The SDK uses ErrSessionMissing for every HTTP 404, while the supervisor did not require an established session ID. The SDK also detaches Streamable HTTP from the Connect context and waits for active calls before closing, so CloseIdleConnections could not force active requests to unwind.

Fix:
Classify a 404 without a session ID as a protocol error with no automatic rebuild. Bind active GET and POST requests to the Reasonix session lifetime, preserve the bounded DELETE cleanup path, and forward idle-connection cleanup through the protected round tripper.

Verification:
Added deterministic sessionless-404 and active-request shutdown regressions. Ran focused normal and race tests, the JetBrains standalone GET/DELETE regression, repolint, and diff checks.
2026-08-25 20:14:38 +08:00
SivanCola 67fd53de67 Split MCP lifecycle code along owner boundaries
Problem:
The completed MCP session migration exceeded repository capacity limits in several long-lived owner files and tests.

Root cause:
SDK adapters, session diagnostics, argument normalization, and deterministic fixtures were initially colocated with broader product surfaces.

Fix:
Extract the new responsibilities into focused owner files, group capability and auxiliary refresh state by lifetime, and keep all runtime behavior and public contracts unchanged.

Verification:
go run ./tools/repolint
go test ./...
go test -race ./internal/agent/... ./internal/plugin/... ./internal/boot/...
cd desktop && go test -race ./...
pnpm --dir desktop/frontend test:all
pnpm --dir desktop/frontend build
2026-08-25 19:59:34 +08:00
SivanCola 68abb7058f test(desktop): close DBus cancellation contexts
Problem: two DBus cancellation tests leaked their contexts when a timeout or t.Fatal path exited before the explicit cancel call.

Root cause: cleanup depended only on the success-path cancellation point.

Fix: defer both cancel functions immediately after context creation while retaining the explicit cancellation that drives each test.

Verification: go test ./... in desktop; go test -race . -run 'Test(WritePending|FlushPending|ConcurrentFlush)' -count=1; go vet ./...; golangci-lint run --timeout=5m ./....
2026-08-25 19:48:17 +08:00
SivanCola 7cac6a8e61 fix(crash): verify paged Firebase migration
Problem: the retained-sample migration loaded all history at once, had no durable resume point, and treated HTTP success as content verification.

Root cause: migration state, lifecycle classification, canonical digests, and Firebase readback were not modeled as an idempotent reconciliation process.

Fix: add 200-group keyset pages, a mode-0600 atomic checkpoint, dry-run capacity/content estimates, three-pass reconciliation, canonical SHA-256 readback, D1 state verification, lifecycle-aware imports, and bilingual rollout/rollback instructions.

Verification: npm run typecheck; npm test; git diff --check; migration unit tests cover active/compacted/archived classification, absolute ring counts, and canonical digests.
2026-08-25 19:48:09 +08:00
SivanCola 711987ff57 fix(crash): bound Firebase Spark storage lifecycle
Problem: Firebase-primary crash storage had no code-level Spark capacity budget or lifecycle state, and stale group writers could race retention or administrative deletion.

Root cause: delivery used a compatibility lease row and whole-group REST updates without durable generations, per-path preconditions, or conservative reservations.

Fix: add additive group state, a fixed 700 MiB reservation budget, generation leases, ETag-fenced path writes, 30/60-day lifecycle processing, delayed tombstone deletion, dashboard state, and sentinel alerts while keeping d1 independent and the production default.

Verification: npm run typecheck; npm test; npx wrangler deploy --dry-run; go run ./tools/repolint; git diff --check.
2026-08-25 19:48:00 +08:00
SivanCola ffa9d2d1fd fix(desktop): satisfy remote tab lint contracts
Problem:
The macOS desktop lint job rejected two new remote-tab constructs under the repository lint profile.

Root cause:
The reconnect loop used a classic integer loop and event dispatch used an if chain instead of the preferred tagged switch.

Fix:
Use Go integer range and a kind switch without changing lifecycle behavior.

Verification:
- cd desktop && golangci-lint run --timeout=5m
- cd desktop && go test ./...
- go run ./tools/repolint
- git diff --check
2026-08-25 19:37:17 +08:00
SivanCola be0c9df945 fix(desktop): harden remote session lifecycle
Problem:
Remote tabs could lose prompts or publish ready across dead streams, and remote UI controls did not preserve local lifecycle contracts.

Root cause:
Serve attachment, hydration, reconnect, and remote composer state were coordinated by independent optimistic paths without one generation/prompt fence.

Fix:
Fence event streams and reattach generations, replay pending prompts, preserve remote session/mode/title/tab identity contracts, and add regression coverage.

Verification:
- go test ./...
- cd desktop && go test ./...
- cd desktop && go test -race . -run 'TestRemoteTab|TestCloseActiveRemoteTab|TestStopRemoteServer' -count=1\n- cd desktop/frontend && pnpm test:all\n- cd desktop/frontend && pnpm build\n- go run ./tools/repolint
2026-08-25 19:26:53 +08:00
SivanCola 0283f75813 Document supervised MCP session behavior
Problem:
The product specification still described Reasonix as owning MCP protocol framing and did not explain the new lifecycle, recovery, pagination, or redacted diagnostics contract.

Root cause:
The documentation reflected the removed custom JSON-RPC transport engine.

Fix:
Document the official SDK boundary, shared supervised sessions, Streamable HTTP listening and cleanup, pagination, notifications, replay policy, and backward-compatible local diagnostics in English and Chinese, with an Unreleased changelog entry.

Verification:
- go test ./...
- cd desktop && go test ./...
- git diff --check
2026-08-25 19:21:55 +08:00
SivanCola 7f8062dd5a Expose MCP session diagnostics in local surfaces
Problem:
MCP failures appeared as generic connection errors, leaving users unable to distinguish listening setup, session expiry, authentication, reconnect progress, or terminal transport failure.

Root cause:
Runtime session state stayed inside the transport and the CLI and Wails contracts exposed only coarse server status.

Fix:
Add optional protocol, session-state, reconnect-count, and error-kind fields to local status surfaces, preserve old payload defaults, and render actionable local diagnostics without exposing session identifiers or credentials.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race ./...
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build
2026-08-25 19:21:55 +08:00
SivanCola c9475ab9c5 Normalize MCP capability call arguments
Problem:
Models sometimes encode an MCP tool argument object as one JSON string, causing use_capability to fail before dispatch with a map unmarshal error.

Root cause:
The proxy forwarded arguments without normalizing the provider's single-layer string encoding, and missing arguments remained null instead of an empty MCP object.

Fix:
Normalize only mcp-tool call arguments before resolution and safety checks. Accept objects, one string-wrapped object, and empty/null values while rejecting arrays, scalars, malformed JSON, and nested strings.

Verification:
- go test ./internal/agent -count=1
- go test -race ./internal/agent/... -count=1
- git diff --check
2026-08-25 19:21:55 +08:00
SivanCola d33bd3e085 Refactor MCP sessions onto the official Go SDK
Problem:
Stateful Streamable HTTP servers could lose their pending session before Reasonix opened the listening stream, while the custom transport engine duplicated protocol correlation, cancellation, and recovery logic.

Root cause:
Reasonix initialized Streamable HTTP with POST requests only and maintained separate request loops for HTTP, SSE, and stdio. Session loss, concurrent rebuilds, pagination, cancellation, OAuth refresh, and cleanup therefore had inconsistent owners.

Fix:
Adopt modelcontextprotocol/go-sdk v1.7.0 for protocol sessions, add a generation-fenced session supervisor, preserve Reasonix transport security and stdio process isolation, and cover JetBrains activation, recovery, pagination, cancellation, OAuth, schema stability, and bounded close behavior.

Verification:
- go test ./internal/plugin -count=1
- go test -race ./internal/plugin -count=1
- go test ./...
- go vet ./internal/plugin
2026-08-25 19:21:55 +08:00
SivanCola dd04a2df47 fix(desktop): redact remote reconnect errors
Problem: Remote serve reconnect failures could write provider credentials embedded in bootstrap errors to desktop logs.

Root cause: reattachRemoteTab logged the complete EnsureServer error even though remote process output can carry forwarded API keys.

Fix: retain structural reconnect diagnostics while omitting the error body, and add a regression test that injects a secret-bearing error and verifies it never reaches the log.

Verification: cd desktop && go test ./...; cd desktop && go test -race ./ -run TestRemoteTabReconnectDoesNotLogEnsureServerSecrets\|TestRemoteTabFollowsHostReconnect -count=1; cd desktop && golangci-lint run ./...; cd desktop && GOOS=windows golangci-lint run ./...; go run ./tools/repolint; git diff --check
2026-08-25 17:49:20 +08:00
SivanCola 23bff7ce46 Merge remote-tracking branch 'origin/main-v2' into fix/pr-9369-approval
# Conflicts:
#	desktop/frontend/scripts/check-bundle-budget.mjs
#	desktop/frontend/src/__tests__/goal-activation-tab-routing.test.tsx
#	desktop/frontend/src/__tests__/startup-settings-contract.test.ts
#	desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx
#	desktop/frontend/src/__tests__/tab-switch-session-rebind.test.tsx
2026-08-25 17:28:58 +08:00
SivanCola 984a537bd6 Fix crash pending schema compatibility
Problem:
Current schema-v3 Desktop diagnostics are retained as unknown instead of uploaded, queue pruning can delete newer-schema pending files, and test-only URL matching and goroutine patterns fail required CI gates.

Root cause:
The pending reader's supported version stopped at schema 2, queue pruning ignored schema ownership, and the tests used broad URL substring checks plus superseded concurrency idioms.

Fix:
- align the pending reader with current schema 3 producers
- preserve future-schema files while pruning only understood queue entries
- use exact OAuth URL checks and CI-compliant deterministic test helpers

Verification:
- focused Desktop pending compatibility tests
- npm run typecheck
- npm test
- golangci-lint run --timeout=5m --allow-parallel-runners
- go run ./tools/repolint
- go test ./...
- cd desktop && go test -race ./...
- go test -race ./internal/crashreport
2026-08-25 17:26:24 +08:00
SivanCola e6658cdc85 fix(desktop): satisfy cross-platform lint gates
Problem:\nThe exact-head CI failed desktop lint on Linux and macOS, plus the root cross-platform build-tag sweep.\n\nRoot cause:\nThe remote-session changes retained an unused pre-snapshot map value, used loops now covered by the slicescontains rule, and ignored an auxiliary preference write without marking it best-effort. Two inherited Unix tray cancellation tests also lacked cleanup on timeout paths.\n\nFix:\nNarrow the remote lookup, use slices.Contains, explicitly keep the last-workspace preference non-fatal after a successful attach, and defer both DBus context cancellations.\n\nVerification:\n- golangci-lint run --timeout=5m ./... (root and desktop)\n- GOOS=windows golangci-lint run --timeout=5m ./... (root and desktop)\n- cd desktop && go test ./...\n- cd desktop && go test -race ./ -run targeted remote/tab/DBus cancellation suite\n- git diff --check
2026-08-25 17:25:11 +08:00
SivanCola e6bb4bae14 Merge pull request #9404 from SivanCola/fix/theme-overlay-token-contract
fix(desktop): make operational theme overlays opaque / 修复主题弹层透明与样式缺失
2026-08-25 17:15:50 +08:00
SivanCola c2914edfcc Migrate crash samples to Firebase Spark storage
Problem:
Crash samples remain in D1 indefinitely and repeated automatic crashes can upload without a version-scoped client boundary.

Root cause:
The ingest protocol has no stable event identity or local deduplication ledger, and the Worker has no bounded, idempotent Firebase projection path.

Fix:
- add event IDs and version-scoped Desktop crash deduplication
- add a bounded D1 outbox, projection receipts, group leases, and OAuth-authenticated Realtime Database delivery
- preserve D1 aggregates with d1, dual, and firebase rollout modes plus additive migration tooling

Verification:
- npm run typecheck
- npm test
- go test ./...
- cd desktop && go test -race ./...
- go test -race ./internal/crashreport
- go run ./tools/repolint
2026-08-25 16:58:50 +08:00
SivanCola 76a3cebd0c test(frontend): follow transactional switch rollback
Problem:\nThe session-rebind suite awaited a tab switch while its target history was intentionally deferred, and still expected the failed target to remain visible after the controller adopted transactional rollback.\n\nRoot cause:\nThe fixture predated switchTab's hydrate-before-complete contract and its safe rebind to the retained source session on history failure.\n\nFix:\nObserve the optimistic target while the switch promise is pending, release the failed history, assert backend and transcript rollback, then retry generation two before exercising the same-tab generation fence.\n\nVerification:\n- pnpm exec tsx src/__tests__/tab-switch-session-rebind.test.tsx\n- git diff --check
2026-08-25 16:55:01 +08:00
SivanCola 26435e9ee8 test(frontend): preserve deferred history race
Problem:\nThe tab-switch hydration suite awaited switchTab before releasing the intentionally deferred target history, leaving the module with an unsettled top-level await.\n\nRoot cause:\nswitchTab now correctly resolves only after target history hydration, while the stale-history race fixture still used the older activation-only completion order.\n\nFix:\nWait for backend activation and the scoped new-session action first, switch back to the cached source tab, then release history and await the original switch promise.\n\nVerification:\n- pnpm exec tsx src/__tests__/tab-switch-hydration.test.tsx\n- git diff --check
2026-08-25 16:49:00 +08:00
SivanCola 34dd6e02be fix(desktop): preserve overlay opacity across theme menus
Route the higher-specificity styled-theme menu rule through the shared operational overlay surface. Expand the browser contract from the jobs popover to every representative operational surface so future selector-specific overrides cannot silently bypass paneOpacity.
2026-08-25 16:46:06 +08:00
SivanCola 5f94ff8d57 test(frontend): follow recommended preset order
Problem:\nThe startup settings contract still required DeepSeek Responses to be the first browser mock rank even after main added the recommended OpenCode Go bundle ahead of it.\n\nRoot cause:\nThe source-shape assertion encoded the old first-rule layout instead of the current two-entry ordering contract.\n\nFix:\nAssert that the recommended bundle ranks first and DeepSeek Responses remains immediately behind it.\n\nVerification:\n- pnpm exec tsx src/__tests__/startup-settings-contract.test.ts\n- git diff --check
2026-08-25 16:44:23 +08:00
SivanCola bb2c812495 test(frontend): isolate remote suites in their loaders
Problem: the generic frontend runner re-executed remote suites that already belong to test:remote, and the connect wizard failed before assertions because plain Node cannot import its CSS module.

Root cause: the PR added the suites to the dedicated script without registering all of them in OWNED_ELSEWHERE.

Fix: assign every dedicated remote suite to test:remote, retaining its CSS and SVG loader contracts and avoiding duplicate coverage.

Verification: run-tests syntax check; full test:remote suite.
2026-08-25 16:38:42 +08:00
SivanCola 453637b147 test(frontend): track hydration-aware tail guard
Problem: the full frontend suite failed its footer-resize source contract after syncing current main-v2.

Root cause: production correctly suppresses tail following during history hydration, but the static contract still expected the pre-hydration guard and dependency list.

Fix: assert the current hydration-aware guard while preserving the original coalesced tail-follow requirement.

Verification: history load failure contract; Goal activation routing test; frontend typecheck.
2026-08-25 16:34:30 +08:00
SivanCola 0a31d5cc0f test(frontend): follow windowed history contract
Problem: the Goal activation routing test reverted its optimistic tab switch because the test harness could no longer load conversation history.

Root cause: after syncing main-v2, useController hydrates through HistorySliceForTab while this PR-owned harness still mocked only the retired page API.

Fix: provide the shared windowed-history mock with the current request type.

Verification: isolated routing test passed five consecutive runs; frontend typecheck.
2026-08-25 16:30:30 +08:00
SivanCola 0693f3f1df fix(desktop): enforce opaque operational theme surfaces
Problem:
Operational popovers such as Background jobs could become fully transparent, while undefined legacy theme variables also removed muted text, hover, border, and shadow styling.

Root cause:
Amber did not inherit a complete semantic surface contract, and operational overlays consumed translucent panel recipes without distinguishing ordinary themes from background-image theme packs.

Fix:
Define and enforce the semantic token contract, replace undefined legacy aliases, derive one overlay surface recipe from theme-pack pane opacity, and cover built-in themes plus portal overlays in a real browser. Keep Remote Connect Wizard CSS in its lazy production entry and align async test fixtures with the current HistorySlice contract.

Capacity:
The raw initial asset budget rises by 1.3 KiB (0.055%) for 1.09 KiB of attributable CSS; the existing gzip CSS ceiling remains unchanged.

Verification:
- pnpm check:css
- pnpm test:theme-browser
- pnpm test
- pnpm build
- git diff --check
2026-08-25 16:29:09 +08:00
SivanCola 6bb4daa1db fix(frontend): preserve remote event mock fallback
Problem: browser tests and non-Wails previews can expose a partial runtime object without the generated Go application binding.

Root cause: the extracted remote event bridge treated window.runtime alone as proof that the native event API was usable.

Fix: require both the generated App binding and the Wails runtime before using native events, matching the original bridge guard and retaining the mock listener fallback.

Verification: frontend typecheck; remote frontend test suite.
2026-08-25 16:24:18 +08:00
SivanCola bdd2953147 fix(desktop): make remote tab activation transactional
Problem: switching from a local session to a remote tab could hide the local surface even when its current session failed to persist. Opening a remote project also mutated remote state before that safety barrier.

Root cause: remote activation bypassed the snapshot-before-switch contract used by local tabs, and local activation cleared the remote selection before the snapshot completed.

Fix: serialize the user-facing cross-registry activation paths, snapshot the current local session before remote activation, keep both registries unchanged on failure, and extract the selector from the oversized tabs file.

Verification: targeted desktop activation tests; targeted race tests; repolint; git diff --check.
2026-08-25 16:24:09 +08:00
SivanCola b092379353 fix(serve): register remote model switch routes
Problem: remote session model and effort changes received HTTP 405 because the JSON endpoints were never registered.\n\nRoot cause: the branch added switch implementations and desktop callers but only kept the legacy slash-command interception inside POST /submit.\n\nFix: register POST /model and POST /effort, validate their JSON payloads, and distinguish busy transition conflicts from internal failures.\n\nVerification: go test ./internal/serve; targeted go test -race ./internal/serve; repository lint.
2026-08-25 16:14:27 +08:00
SivanCola bccf46f082 Merge latest main-v2 into remote session surface
Preserve the new local session-archive flow while routing remote session rename, pin, open, and delete actions through the remote bridge.\n\nVerification: pnpm typecheck; remote project tree contract; project tree archive race; repolint.
2026-08-25 16:10:08 +08:00
SivanCola e3830bfe85 Merge main-v2 and harden remote session surfaces
Problem: the remote session branch predated the project credential-chain merge and its tab bridge allowed duplicate opens, stale SSE generations, unbounded responses, and inconsistent mixed-tab persistence.\n\nRoot cause: remote session ownership, transition serialization, and local/remote tab ordering were not represented as single synchronized state machines.\n\nFix: merge main-v2, preserve the modular remote project bridge, serialize session transitions, generation-fence event pumps, harden loopback HTTP transport and response limits, preserve mixed tab state, and split the frontend/backend additions across focused modules.\n\nVerification: go test ./...; targeted go test -race remote suite; pnpm typecheck; pnpm test:remote; pnpm build; go run ./tools/repolint.
2026-08-25 16:08:54 +08:00
SivanCola ecce16e989 Merge pull request #9376 from Linearl/fix/document-memory-capability
fix(agent): document memory: capabilities in use_capability description / 在 use_capability 描述中说明记忆工具用法
2026-08-25 15:20:29 +08:00
SivanCola ecc69d90af Merge pull request #9397 from SivanCola/feature/release-notes-client-sections
Split release notes by Desktop and CLI / 按桌面端与 CLI 拆分更新日志
2026-08-25 15:15:51 +08:00
SivanCola 090895e2e0 Merge pull request #9374 from Linearl/fix/session-archive-close-interrupted
fix(desktop): archive individual sessions safely in project tree / 项目树安全归档分组内单个会话
2026-08-25 15:09:32 +08:00
SivanCola d9c9e978de Merge pull request #9368 from XTLine/feat/remote-project-groups
feat(desktop): remote workspace onboarding — project-tree groups and credential chain / 远程工作区接入:项目树分组与凭证链 [0.5/3]
2026-08-25 14:59:44 +08:00
SivanColaandLinearleaf 3bb39505c7 fix(desktop): keep archive failures visible
Problem:
- Active runtime-session rows could invoke DeleteSession and silently stop ongoing work.
- Topic tombstones were installed before TrashTopic committed, so refreshes could hide a topic even when the backend later rejected the archive.

Root cause:
- The per-session menu ignored the row's existing active-work archive guard.
- Pending UI state and the post-commit stale-response fence shared one lifecycle.

Fix:
- Disable per-session archive actions for thinking, streaming, confirmation, and background-job states.
- Commit topic tombstones only after backend success, fence pre-commit page loads at that boundary, and keep failure recovery visible.
- Extend the deterministic archive interleaving regression coverage.

Verification:
- pnpm exec tsx src/__tests__/project-tree-archive-race.test.ts
- pnpm exec tsx src/__tests__/project-tree-runtime.test.ts
- pnpm test:typecheck
- pnpm build
- pnpm test:remote
- cd desktop && go test -count=1 -run 'TestDeleteSessionCancelsActiveRuntime|TestDeleteSessionWithStuckJobReturnsAfterSingleGrace|TestTrashTopicRejectsRunningSessionRuntime|TestTrashTopicRejectsConcurrentTurnAdmissionWithoutWaiting' .
- go run ./tools/repolint
- git diff --check

Co-authored-by: Linearleaf <[email protected]>
2026-08-25 14:43:45 +08:00
SivanCola 4b2bbcce3e Merge remote-tracking branch 'origin/main-v2' into fix/pr-9368-approval 2026-08-25 14:33:35 +08:00
SivanCola f215b0d517 fix(remote): keep credential context out of logs
Problem:
CodeQL reported a high-severity clear-text logging path from provider credential configuration into the legacy Serve restart failure log.

Root cause:
The asynchronous fallback logged the complete error returned by EnsureServer. That error chain can include credential-configuration context and should not be persisted even though current messages avoid key values.

Fix:
Log only the safe host and workspace correlation fields for this restart failure. The detailed failure remains available through the managed Serve state without entering process logs.

Verification:
- go test ./... in desktop
- targeted remote desktop race suite
- golangci-lint v2.12.2
- repolint and git diff --check
2026-08-25 14:33:09 +08:00
SivanCola e5924af73c fix(agent): preserve memory activation semantics
Problem: The provider-visible memory guidance could make an update pass activation="relevant" and silently unpin an existing pinned memory, while forget and recall omitted their minimum arguments.

Root cause: The compact use_capability description conflated create and update defaults instead of matching the underlying memory schemas.

Fix: Document create/update activation behavior, the pinned opt-in, forget(name), and recall operations; enforce the contract with focused assertions; regenerate all cache-prefix goldens.

Verification: go test ./...; go test -race ./internal/agent -run TestUseCapabilityMemoryDescriptionNamesRoutableTools -count=1; go vet ./internal/agent ./internal/boot; go run ./tools/repolint; go build ./cmd/reasonix; go build ./internal/agent/... ./internal/capability/...

Co-authored-by: SivanCola <[email protected]>
2026-08-25 14:31:56 +08:00
SivanCola 273b4bf37e fix(productdocs): use slices for release targets
Problem:
The CI lint job rejected the manual target lookup loop under the Go 1.26 modernize rules.

Root cause:
releaseItemHasTarget duplicated the standard slices.Contains operation.

Fix:
Use slices.Contains for the same exact membership check without changing release rendering behavior.

Verification:
- go test ./internal/productdocs
- go test ./...
- golangci-lint run --timeout=5m --allow-parallel-runners
- go run ./tools/repolint
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- git diff --check
2026-08-25 14:31:44 +08:00
SivanCola 1259cbcdc0 Merge pull request #9381 from hdkshingala/fix/acp-host-session-mcp-dispatch
fix(mcp): dispatch host-session MCP servers supplied over ACP
2026-08-25 14:27:09 +08:00
SivanCola ab55d0c6e3 fix(release-notes): require target metadata for future releases
Problem:
The legacy compatibility path could let manually authored v1.31.4 and newer records bypass per-client target metadata.

Root cause:
Validation required item targets only when targetingVersion was already present, without an adoption boundary for future releases.

Fix:
- require targetingVersion 1 for v1.31.4 and newer core versions
- preserve legacy rendering and validation for older releases
- cover the adoption boundary across stable and preview versions

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- bash scripts/release-workflows.test.sh
- node scripts/check-single-release-public-contract.mjs
- git diff --check
2026-08-25 14:23:52 +08:00
SivanCola 8f73d8b0f4 test(desktop): synchronize quality floor fixture
Problem:
The desktop full race suite could fail in TestMetaReportsGoalStatus while an asynchronous tab metadata refresh read the quality floor.

Root cause:
The test directly mutated WorkspaceTab.qualityFloor without App.mu after MetaForTab had scheduled a background refresh. Production writes already use the app lock, but the fixture bypassed that contract.

Fix:
Guard the legacy empty-floor fixture mutation with App.mu so the test follows the same synchronization boundary as production code.

Verification:
- go test -race . -run TestMetaReportsGoalStatus -count=50
2026-08-25 14:14:43 +08:00
SivanCola 49ebd56dfd feat(release-notes): split updates by client surface
Problem:
Release notes combine Desktop and CLI changes, so users cannot tell which client needs an update.

Root cause:
The release ledger has no item-level target metadata, and every renderer flattens the same product-wide sections.

Fix:
- add versioned Desktop, CLI, site, and service targets with strict validation
- derive target hints from pull request labels and changed paths
- render client-specific sections across GitHub notes, the website, and built-in docs
- preserve the legacy layout for historical releases

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- go test ./internal/productdocs
- cd site && npm test
- go test ./...
- cd site && npm run build
- bash scripts/release-workflows.test.sh
- node scripts/check-single-release-public-contract.mjs
- actionlint release workflows
- go run ./tools/repolint

Refs #9394
2026-08-25 14:05:09 +08:00
SivanCola 57f64e0bcd Merge remote-tracking branch 'origin/main-v2' into fix/pr-9368-approval 2026-08-25 14:03:35 +08:00
SivanColaandXTLine 5a09a772a2 fix(remote): harden project groups and credential proxy
Problem:
The project-groups stage exposed unfinished remote-tab actions and could lose per-workspace credential routes or provider endpoints after reconnects. Proxy forwarding, request-body handling, remote path resolution, and stale UI fetches also had security and race gaps.

Root cause:
The partial implementation mixed future tab plumbing with the current remote-window surface, used host-scoped assumptions for workspace state, and mutated shared proxy/server state without complete lifecycle fences.

Fix:
Keep the UI on the supported remote-window flow, persist and render project registrations safely, isolate workspace provider/token identities, heal all tracked routes and managed provider blocks on reconnect, harden the credential proxy and SFTP bootstrap, and split oversized modules without widening repolint baselines. Add focused frontend, Go, race, security, reconnect, and multi-workspace coverage.

Verification:
- pnpm build and pnpm test:remote
- Playwright motion and transcript browser gates
- go test ./... in the root and desktop modules
- CI concurrency-package and desktop full race suites
- Windows cross-compilation for changed root packages and desktop
- go vet, golangci-lint v2.12.2, and repolint

Co-authored-by: XTLine <[email protected]>
2026-08-25 14:03:20 +08:00
SivanCola 0a98eca6c2 Refactor host-session MCP dispatch ownership
Problem: The functional fix grew already-large agent, boot, and controller owner files past their repolint ratchets, and its tests asserted a production-only boolean accessor instead of exercising the real proxy route.

Root cause: Availability helpers and inventory merge policy lived in aggregate files, while the regression test stopped at the dispatch gate rather than crossing use_capability into tools/call.

Fix: Move MCP availability and host-session inventory logic into focused owner files, remove the Controller accessor, and drive the Streamable HTTP stub through an actual use_capability call while checking disabled and unknown refusals and provider-schema containment.

Verification: go run ./tools/repolint; go test ./...; focused go test -race for internal/boot, internal/agent, and internal/control; go vet for the owning packages; gofmt and git diff --check.
2026-08-25 13:44:33 +08:00
SivanCola 931288de16 Merge remote-tracking branch 'origin/main-v2' into HEAD
# Conflicts:
#	desktop/frontend/src/components/ProjectTree.tsx
2026-08-25 13:38:03 +08:00
SivanCola 9105163cab Merge pull request #9364 from zhongerdeajie/fix/windows-taskbar-running-window-icon
fix(windows): restore taskbar icon on the running desktop window / 修复运行窗口任务栏图标显示空白
2026-08-25 13:36:14 +08:00
SivanColaandLinearleaf 5a29ca5317 fix(desktop): isolate per-session archive actions
Problem:
- Grouped session rows shared topic-scoped menu and confirmation state.
- Successful session deletion refreshed only project shells, so the archived row could remain visible.
- The interrupted-job filters in the PR targeted a running-only API and could never observe interrupted tombstones.

Root cause:
- Session actions were keyed by topicId instead of exact row/session identity.
- The session archive path did not reload its owning canonical topic page.
- jobs.Manager.RunningForSession deliberately keeps unwinding work operationally running while excluding closed tombstones.

Fix:
- Key menus by row and confirmations by scoped topic/session identity.
- Serialize session archive actions, expose per-session busy state, and reload the owning project page.
- Preserve the existing running-job accounting contract and add focused regression coverage.
- Extract the session archive menu to keep ProjectTree within its repository budget.

Verification:
- pnpm build
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/project-tree-archive-race.test.ts
- go test -count=1 ./internal/jobs ./internal/control
- cd desktop && go test ./...
- go vet ./...
- cd desktop && go vet ./...
- go run ./tools/repolint

Co-authored-by: Linearleaf <[email protected]>
2026-08-25 13:32:52 +08:00
SivanCola 2e69bddcb3 Merge pull request #9158 from Linearl/fix/9060-compact-ratio-floor
fix(config): relax compact_ratio floor from 0.65 to 0.30 / 放开上下文压缩阈值下限
2026-08-25 13:28:22 +08:00
SivanCola 5051fd2bd6 Fix documented memory capability routes
Problem: The model-facing description advertised an unregistered memory:recall route, and the cache-prefix golden did not record the intended schema change.

Root cause: The prose was not checked against registry routing, while only the serialized request/schema goldens were refreshed.

Fix: Document the registered tool:memory recall route, tighten pinned guidance, add a routing regression test, and regenerate all cache-prefix goldens.

Verification: go test ./internal/agent ./internal/boot; go vet ./internal/agent ./internal/boot; go run ./tools/repolint; go build ./cmd/reasonix; go build ./internal/agent/... ./internal/capability/...

Co-authored-by: SivanCola <[email protected]>
2026-08-25 13:14:51 +08:00
SivanCola 6740659465 fix(windows): use the correct small icon selector
Problem:
The window icon repair sent WM_SETICON with selector 2, which is only valid as ICON_SMALL2 for WM_GETICON. The per-window small icon therefore remained unset.

Root cause:
The getter-only small-icon selector was confused with WM_SETICON's ICON_SMALL value.

Fix:
Use selector 0 for the small window icon, expose narrow Win32 call seams so tests assert the exact selectors and class assignments, strengthen the no-icon guard, and document the bounded process-lifetime handle retention accurately.

Verification:
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go vet .
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c .
- go test ./...
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-25 13:14:02 +08:00
SivanCola 1076795d92 Merge origin/main-v2 into feat/remote-project-groups 2026-08-25 12:38:28 +08:00
SivanCola c5d1ef0533 Merge pull request #9367 from XTLine/feat/remote-connect-wizard
feat(desktop): remote workspace onboarding — three-step connect wizard / 远程工作区接入:三步连接向导 [0/3]
2026-08-25 12:28:20 +08:00
SivanCola aa855246cd Make remote onboarding independently shippable
Problem:
- The wizard finished through remote-tab bindings that are introduced only by a later stacked PR.
- Browser file inputs cannot provide the absolute SSH identity path the desktop process needs.
- Modal focus, retry concurrency, and directory-response ordering were not protected.

Root cause:
- Browser mocks hid the Wails/native boundary and the stack assumed the later project-tab layer was already present.
- Connection retry and SFTP listing work had no single-flight or request-generation ownership.
- Wizard styles lived in the app-shell stylesheet.

Fix:
- Finish through the existing OpenRemoteWorkspace surface and remove later-layer API/config additions.
- Add a native identity-file picker and an early Linux/macOS platform probe.
- Trap and restore modal focus, hold retry busy state, and ignore stale directory responses.
- Extract growing modules, lazy-load wizard CSS, tighten bundle gates, and document the flow.

Verification:
- go build ./... && go vet ./... && go test ./...
- cd desktop && go build ./... && go test ./...
- go test -race ./internal/config
- cd desktop && go test -race . -run 'Test(PickRemoteIdentityFile|CheckRemotePlatform|Remote)'
- pnpm test:remote
- pnpm build
- REASONIX_CHANNEL=test pnpm build
- go run ./tools/repolint
- 1024x600 browser geometry, overflow, keyboard focus, and lazy CSS validation

Co-authored-by: SivanCola <[email protected]>
2026-08-25 12:03:56 +08:00
SivanCola 68a08fa12f Merge pull request #9153 from zcxads666/fix/macos-self-update-non-apfs-rename
Merge the macOS exFAT self-update compatibility fix and best-effort rename semantics.
2026-08-25 11:49:28 +08:00
SivanCola c946e432ea Merge main-v2 into remote connect wizard
Problem: PR #9367 was based on an older main-v2 and conflicted with the current frontend bundle contract.\n\nRoot cause: main-v2 advanced provider access cleanup, branch metadata recovery, telemetry, LaTeX rendering, and bundle-budget baselines while the wizard branch changed the same frontend budget surface.\n\nFix: merge current main-v2 and retain both the KaTeX fallback baseline and the wizard's measured bundle allowance.\n\nVerification:\n- go build ./... && go vet ./... && go test ./...\n- cd desktop && go build ./... && go test ./...\n- cd desktop/frontend && pnpm build
2026-08-25 11:39:20 +08:00
SivanCola 264a937fac chore: ratchet repolint budget for compact ratio PR
Problem: the synchronized PR head exceeded existing repolint file-size and test-file-size budgets by small, intentional deltas in touched files.

Root cause: the latest main-v2 baseline predated this PR's compact-ratio validation, UI, CLI, and regression-test additions.

Fix: update only the six measured baseline entries required by the PR; no broad slack or unrelated baseline changes.

Verification: go run ./tools/repolint; go test ./internal/config ./internal/cli -count=1; git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-25 11:31:55 +08:00
SivanCola 97411a34d5 Merge pull request #9386 from esengine/release-notes/v1.31.4
docs(release): 准备 v1.31.4 中英更新日志
2026-08-25 09:32:08 +08:00
github-actions[bot] 91633c204f docs(release): prepare v1.31.4 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-25 01:28:44 +00:00
LinearleafandSivanCola aa36888148 fix(agent): rebuild zero-filled corrupt branch meta instead of failing saves / 修复:全零损坏的会话 meta 自动重建,不再阻断保存/关闭 (#9155)
* fix(agent): rebuild zero-filled corrupt branch meta instead of failing saves

A branch-meta sidecar can be torn into all-NUL (or all-whitespace) bytes when a
Windows non-atomic ReplaceFile in-place copy is interrupted by a forced reboot
(DPC_WATCHDOG_VIOLATION / power loss) — see #6325. Every save/close then fails
with 'decode branch meta: invalid character \x00' and the tab cannot be closed,
because LoadBranchMeta surfaces the decode error and callers (save.go:807,
ensureBranchMetaUnlocked) have no rebuild fallback.

Treat all-zero / all-whitespace undecodable meta as absent so it is rebuilt on
the next save, instead of blocking saves/closes. Genuine partial JSON (non-zero)
still returns an error so real corruption is not silently swallowed.

Tests: zero-filled -> absent, whitespace-only -> absent, partial JSON -> error,
EnsureBranchMeta rebuilds a corrupt sidecar. Refs #6325.

* docs(agent): clarify corrupt meta detection comment

Problem: the helper comment implied valid JSON cannot begin with whitespace.
Root cause: the wording conflated a JSON prefix with a document consisting only of JSON whitespace.
Fix: describe the actual all-NUL/all-whitespace invariant used by the detector.
Verification: git diff --check; internal/agent tests will run after updating the branch base.

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-08-25 00:17:34 +00:00
SivanCola 602b0ddbde fix(desktop): clarify exFAT rename fallback semantics
Problem: filesystems without RENAME_EXCL cannot provide an atomic no-replace rename through the fallback path.

Root cause: the fallback used an existence check followed by os.Rename without documenting the Reasonix lock boundary or surfacing its best-effort errors.

Fix: document the cooperative mutation-lock boundary and wrap fallback conflicts and rename failures with explicit best-effort context while preserving os.ErrExist matching.

Verification: go test ./internal/repair -count=1 -timeout=180s; cd desktop && go test . -run 'TestMacUpdateRename' -count=1 -timeout=180s
2026-08-25 08:04:39 +08:00
SivanCola 0a9680d4e3 chore: sync compact ratio PR with latest main-v2
Problem: the PR branch was based on an outdated main-v2 and carried avoidable integration risk.

Fix: merge the latest origin/main-v2 into the PR branch without rewriting the contributor history; the effective three-dot diff remains limited to the compact-ratio implementation, tests, and documentation.

Verification: merge-tree is clean; go test ./internal/config ./internal/cli ./internal/agent -count=1; go test ./desktop -run TestSetCompactRatio -count=1; frontend settings snapshot, typecheck, and eslint pass.
2026-08-25 08:02:00 +08:00
zcxads666 4c3924baaf fix(desktop): support macOS updates on exFAT
Use a macOS-updater-only rename fallback for filesystems without
RENAME_EXCL and compare copied app payloads without POSIX mode or
matching AppleDouble noise. Keep strict backup and staging identities
while accepting legacy strict handoff digests.
2026-08-25 08:01:35 +08:00
SivanCola 63d87594bc Merge pull request #9115 from yuhuping/fix/jobs-quiet-job-stall-notice
fix(jobs): stop calling quiet background jobs "stalled" / 修复安静后台任务被误报为“卡住”
2026-08-25 07:57:43 +08:00
SivanCola 02fe40a077 Merge pull request #9356 from SivanCola/fix/atomic-opencode-go-group-removal
Fix atomic OpenCode Go group removal / 修复 OpenCode Go 分组原子删除
2026-08-25 07:56:25 +08:00
Hardik ShingalaandClaude Opus 5 7d88bbe837 docs(boot): restore applyUnifiedProviderToolSurface's doc comment
mergeHostSessionCapabilitySpecs was inserted between
applyUnifiedProviderToolSurface's doc comment and its declaration, so the new
function carried a paragraph describing a different function and
applyUnifiedProviderToolSurface was left undocumented.

Move the helper and its comment below applyUnifiedProviderToolSurface. Both
identifiers are unexported, so gofmt, go vet and staticcheck's comment rules all
stay silent on this — only a human reading the file top to bottom sees it.

Pure move: 19 lines out, the same 19 in, no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 03:35:30 +05:30
Hardik ShingalaandClaude Opus 5 7458b1abbd fix(mcp): state host-session precedence and unify the unregistered message
Follow-up to review on the host-session MCP dispatch fix.

Extract the capability-runtime inventory merge into
mergeHostSessionCapabilitySpecs. The previous change appended host-session specs
after the config specs and relied on ConfigureServers building its registry with
next[name] = ... in slice order, so a name collision resolved to the
host-session spec by map overwrite, 700 lines from where it was decided.

That precedence is correct — it is the same choice the registration tier already
makes explicitly, filtering colliding config specs out of configSpecs before
registerEnabledMCP — but it should be stated, not inherited from slice order.
Reorder the append or switch ConfigureServers to first-wins and the behaviour
silently inverts into the worst case: a user-disabled config spec marked enabled,
with an identity that no longer matches the tools pluginHost registered.

The merge also drops the shadowed config *entry*. ConfigureServers pairs an entry
with a spec by name, so a surviving config entry paired auto_start = false with
the live host-session spec, and /mcp reported one server as simultaneously
not-auto-start and not-disabled.

Unify the wording for "this session has no such MCP server". After the previous
change the same state had two spellings in one file: the three use_capability
sites said "is not registered in this session" while lockAuthorizedRuntimeServer
— the path a registry-resident mcp__server__tool takes — still said "is not
configured". Both are reachable. mcpServerUnregisteredMessage and
mcpServerDisabledMessage now hold the two spellings that are meant to differ.

Tests:

- TestMergeHostSessionCapabilitySpecsPrefersHostSessionOnNameCollision pins the
  precedence rule and that the shadowed entry is dropped. Asserted on the merge
  directly rather than through a booted Controller, since observing which spec
  reached the registry would otherwise need a production accessor existing only
  for the test.
- TestMergeHostSessionCapabilitySpecsIsIdentityWithoutHostSession pins that the
  ordinary config path is handed through untouched.
- TestBuildLeavesDisabledConfigMCPUndispatchableAlongsideHostSession pins that a
  config server with auto_start = false stays undispatchable while an unrelated
  host-session server is enabled in the same boot — the invariant the enablement
  loop could break by widening from "every extraSpec name" to "every name".

Both new behavioural tests were mutation-checked: replacing the merge with a
naive append fails the collision test, and widening the enablement loop to every
config name fails the disabled-config test.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 03:24:35 +05:30
Hardik ShingalaandClaude Opus 5 5c82153401 fix(mcp): dispatch host-session MCP servers supplied over ACP
An MCP server passed through ACP `session/new.mcpServers` arrives as
`Options.ExtraPlugins` and is appended to `eagerSpecs`, so it reaches
`pluginHost`: it connects, completes initialize + tools/list, and its tools
appear in the capability catalog as `status: "ready"`.

The capability runtime is a second, separate registry. It is seeded by
`capRuntime.ConfigureServers(cfg.Plugins, capSpecs, enabledMCPNames)`, and both
`capSpecs` and `enabledMCPNames` were built from config alone. A host-session
server was therefore absent from `r.servers`, so `serverEnabled()` returned
false on its `ok` check and every `mcp-tool:<server>/<tool>` call was refused
with "is disabled in this session" — while the catalog had just advertised the
same tool as ready.

Register host-session specs with the capability runtime and mark them enabled,
matching the intent already stated where they are built: they arrive through an
explicit host action, so they are authoritative exactly like an auto-started
config plugin. Both changes are required; neither alone is sufficient, since
`ConfigureServers` reads an absent `enabled[name]` as false and `serverEnabled`
rejects a name missing from `r.servers`.

Also separate "not registered" from "disabled" in the refusal message. They have
different causes and different fixes, and reporting the former as the latter
sent users looking for a toggle that does not exist.

Reproduced against v1.31.3 over the real ACP wire with zero `[[plugins]]` in
config: `use_capability` `action: "call"` on
`mcp-tool:acppassed/traycer_probe_ping` returned "disabled in this session" and
the MCP server never received `tools/call`; `inspect` on
`mcp-server:acppassed` returned `unknown capability_id`. With this change the
same probe returns the tool result and the server receives `tools/call`.

Both new boot tests were mutation-checked: reverting only the boot.go change
fails TestBuildEnablesHostSessionMCPForCapabilityDispatch while
TestBuildLeavesUnknownMCPServerUndispatchable keeps passing, so the fix cannot
be satisfied by defaulting unknown servers to enabled.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 02:49:48 +05:30
SivanCola ce9fa50c04 Merge pull request #9141 from lightfront/fix/desktop-katex-boxed-frame-collapse
fix(desktop): boxed frame collapse
2026-08-25 01:34:09 +08:00
SivanCola e42b63ebb0 chore(desktop): budget boxed math CSS headroom
Problem: the latest-base app-shell CSS budget was 30 gzip bytes below the boxed KaTeX fallback build.

Root cause: the rendering fix adds a small, intentional stylesheet rule at a rounded 114.3 KiB gate.

Fix: raise only the deferred app-shell CSS allowance to 114.4 KiB and document the measured 30-byte reason.

Verification: pnpm build passes with the updated budget.
2026-08-25 01:14:44 +08:00
SivanCola 837b9dae19 fix(config): expose compact ratio floor across all settings
Problem: the config setter accepted ratios below 0.65 while the CLI, desktop editor, mock bridge, tests, and docs still enforced the old floor.

Root cause: compact-ratio bounds were duplicated across Go and TypeScript surfaces.

Fix: centralize Go bounds at 0.30-0.85, apply the range to CLI and desktop settings, update localized guidance and documentation, and add lower-bound regression coverage including 1M-context trigger math.

Verification: go test ./internal/config ./internal/cli ./internal/agent; go test ./desktop -run TestSetCompactRatio -count=1; frontend settings snapshot, typecheck, and eslint pass.

Co-authored-by: SivanCola <[email protected]>
2026-08-25 00:59:29 +08:00
SivanCola 9fb0a2858b fix(desktop): guard boxed math width across browsers
Problem: KaTeX boxed frames can collapse in WebKit, and the frontend package pin and CSS contract were not guarded by an automated test.

Root cause: the WebKit-specific fill value had no standards-compatible fallback or source contract test.

Fix: keep width:100% as a fallback, retain -webkit-fill-available for WebKit, and add a discovery-run contract test for the boxed rule and pnpm pin.

Verification: go test ./internal/cli; go vet ./internal/cli; CSS syntax and z-index checks; math-golden; katex-boxed-css contract test.
2026-08-25 00:53:50 +08:00
SivanColaandpoorpaper a215352b6f fix: preserve scoped provider removal semantics
Problem:
Grouped custom-provider removal expanded search-only requests to every OpenCode Go route and left auxiliary model references pointing at deleted providers.

Root cause:
The batch API did not preserve the caller's requested subset, while the removal fingerprint and retargeting path covered only the primary desktop model references.

Fix:
Delete only the explicitly requested members of a validated OpenCode Go family. Clear vision references, retarget guardian/recovery and every persisted bot model owner, and include those values in the concurrent-change fingerprint.

Verification:
- go run ./tools/repolint
- cd desktop && go vet ./... && go test ./...
- focused grouped-removal tests under go test -race
- provider access card component test
- frontend production and test TypeScript checks

Co-authored-by: poorpaper <[email protected]>
2026-08-25 00:53:39 +08:00
SivanCola 4ae018529d fix(jobs): keep quiet-job notices within lint budget
Problem: the notice wording fix pushed jobs.go beyond the repository repolint baseline, and the updated tests did not fully guard the new Text/Detail contract.

Root cause: the notice payload was split across extra local variables and a multi-line event literal, while the tests only searched for the new headline.

Fix: construct the unchanged warning event in a compact value, and assert the new wording, guidance, and removal of the old stalled phrase in both emitted and drained notices.

Verification: go test ./internal/jobs/ ./internal/config/; go test -race ./internal/jobs; go vet ./internal/jobs/ ./internal/config/; go run ./tools/repolint.

Co-authored-by: SivanCola <[email protected]>
2026-08-25 00:52:32 +08:00
SivanCola 69cf62ca85 Merge remote-tracking branch 'origin/main-v2' into fix/desktop-katex-boxed-frame-collapse 2026-08-25 00:51:28 +08:00
yuhuping 82026885f0 fix(jobs): stop calling quiet background jobs "stalled"
A background job with no output for tools.background_jobs.stalled_warning_seconds
(900s by default) was reported as "may be stalled". Many legitimate long-running
tasks (installs, builds, test collection, waiting on an external service) stay
silent for minutes without being stuck, so a single "no visible output" signal
cannot distinguish a hang from a healthy quiet job and produced noisy false
warnings.

Reframe the notice so it states the observable fact — still running after N
without new output, i.e. a heads-up, not an error — and points at how to tune or
disable the check (stalled_warning_seconds = 0). Applies to the drain note, the
emitted Notice Text/Detail, and the config comments and rendered output.

Tests: go test ./internal/jobs/ ./internal/config/ (all pass).
2026-08-25 00:43:33 +08:00
YHH 58ebc988c9 perf(crash-worker): the dashboard's rarest number stops costing the whole D1 bill (#9379)
The preferences module's 30-day deduplicated install count was computed by an
hourly cron over metric_users. That table's primary key leads with `date`, so
`WHERE date >= ... AND signal = ?` could only prune the window: every signal
scanned all ~28M rows, eight signals an hour. One dashboard panel therefore
read on the order of five billion rows a day, which is the database's entire
read bill — and metric_users had no other reader.

Retire the detail table and everything that fed it: the rollup cron and its two
tables, the per-install metrics write, the dashboard's own full-table window
query, and the runtime bootstrap that would recreate cli_metric_users on the
next request. The aggregate `metrics` tables keep the same signals as event
counts at ~180k rows, and `pings` keeps per-day deduplicated installs.

Both panels that rendered the retired number are removed rather than left
showing a permanent error — the 30-day notice linked to a 7-day view that no
longer had data either. The client stops sending install_id with metrics, since
nothing receives it now.

migrate-drop-metric-users.sql reclaims the ~5.6GB the table held. Deploy the
worker before running it: until then the runtime bootstrap rebuilds the table.
2026-08-24 09:40:36 -07:00
Linearleaf 034afa9c2b fix(agent): document memory: capabilities in use_capability description / 在 use_capability 描述中说明记忆工具用法 2026-08-24 18:13:49 +08:00
Linearleaf ed0beef744 fix(desktop/control): interrupted jobs no longer block close/archive 2026-08-24 17:46:05 +08:00
Linearleaf 1760e4e70f feat(desktop): add per-session archive action in project tree 2026-08-24 17:45:59 +08:00
Linearleaf 46863c51b6 fix(desktop): keep group visible when topic archive is rejected 2026-08-24 17:45:53 +08:00
XTLine 4425092027 Merge feat/remote-connect-wizard into feat/remote-project-groups
Carry the main-v2 merge (and its styles.css resolution) into [0.5/3]
so GitHub can auto-merge the groups PR as well.
2026-08-24 15:54:35 +08:00
XTLine 56c677451a Merge branch 'main-v2' into feat/remote-connect-wizard
Bring the four post-base main-v2 commits (#9312/#9314/#9341/#9344) onto
the wizard branch so GitHub can auto-merge the [0/3] PR. The only conflict
was styles.css (duplicate transcript geometry-pending block); keep the
main-v2 copy.
2026-08-24 15:54:27 +08:00
zhongerdeajie 72804d8acf docs: trigger docs-impact re-run 2026-08-24 15:05:17 +08:00
XTLine 994c56f0be test(desktop-fe): remote session suites, locales, styles closeout / 远程会话测试、语言与样式收尾 [1/3]
- remote-session-surface / remote-running-reconcile / persist suites
- transcript-rows, app-chrome-tabs, history-tool-status updates
- locales en/zh/zh-TW and styles.css to final; runner + budgets closeout
2026-08-24 14:48:45 +08:00
XTLine 77a14bf3ee feat(serve,remote): session-plane endpoints and final kernel state / 会话面端点与内核终态 [1/3]
- serve.go: session-plane endpoints, model/effort routing to final
- desktop/kernel + internal (agent/control/event/eventwire/config/cli/
  bootstrap/sftpfs) to final state
2026-08-24 14:48:39 +08:00
XTLine eb4ea9c455 feat(desktop-fe): remote sessions ride the local renderer with full parity / 远程会话全面复用本地渲染管线 [1/3]
- RemoteSessionSurface + useRemoteSession: transcript through the local
  pipeline end to end, strip activation/close, running reconciliation,
  submit rollback, interruption notices
- App: shared Composer wired to the remote session; ProjectTree session
  rows click-through, titles, merged-project wizard polish
- bridge/types to final; svg-loader, crash guard, index.html spinner fix
2026-08-24 14:48:31 +08:00
XTLine 40af7a7cd2 feat(desktop): remote tab serve bridge — handshake, SSE pump, lifecycle / 远程标签 Serve 桥:握手、事件泵与生命周期 [1/3]
- remote_tab.go: auth handshake, session entry, single-pump attach,
  reconnect/close lifecycle, full command proxy, snapshot; the listing
  slice from [0.5/3] folds back in here
- remote_projects/tabs/app/session_catalog: authoritative session
  lifecycle, titles, new-session reset, disconnected-shell persist and
  restore, reverse-channel self-healing
2026-08-24 14:48:23 +08:00
XTLine aaab10fd89 chore(desktop-fe): ratchet bundle budgets at project groups / 项目分组段预算 ratchet [0.5/3]
- initial JS 432.5→434.5, app-shell CSS 115.4→115.8,
  zh 57.2→57.6 / zh-TW 58.0→58.6, raw startup 2364.0→2373.0 (measured)
2026-08-24 14:48:05 +08:00
XTLine 44a38fae6e feat(desktop-fe): wizard polish, tree styles, remote test hardening / 向导打磨、树样式与远程测试加固 [0.5/3]
- wizard credential-mode segmented control; connect-log and finish polish
- project-tree group/badge/menu styles (d597b10fd subset)
- remote-error-ux pinned to jsdom navigator (fixes the 5 pre-existing
  locale failures); hosts-page/secret-dialog/server-panel mocks to final
2026-08-24 14:43:38 +08:00
XTLine 033c9ac19a feat(desktop): desktop-held credentials for remote serves / 远程 serve 桌面持钥模式 [0.5/3]
- desktop cred_proxy: virtual-token key holder with reverse-tunnel
  delivery; per-workspace routes, ephemeral remote ports, watchdog heal
- bootstrap: local-proxy credential kernel (provider install/heal +
  change signal, mismatched-serve replacement) and .env token delivery
- serve: provider-reload endpoint, provider_unreachable event, status
  reconciliation fields (agent/control/event/eventwire)
- wizard: credential-mode segmented control; hosts page + panel/store
  tests to final; sftp upload budget + multiworkspace coexistence
2026-08-24 14:41:00 +08:00
XTLine a4f1169326 fix(remote): generous serve/upload budget and pipelined SFTP writes / 放宽 serve 上传时限并流水线化 SFTP 写入 [0.5/3]
- sftpfs pipelined writes with a generous upload budget; remote_connect
  follows suit; multi-workspace serve coexistence coverage
2026-08-24 14:20:11 +08:00
XTLine be906c64b9 feat(desktop): remote session listing for the project tree / 项目树远程会话列表 [0.5/3]
- remote_listing.go (slice of the serve bridge, folded back into
  remote_tab.go by the sessions PR): one-shot auth handshake against an
  already-ready serve registration, GET /sessions, RemoteProjectSessions
- ServeSnapshot read-only lookup on the per-workspace registry: query
  paths must never cold-start a serve; live-tab fast path lands with [1/3]
2026-08-24 14:20:00 +08:00
XTLine 4defd9da12 feat(desktop): per-workspace remote serve registry, stop/status/logs API / 按工作区键控的远程 serve 注册表与停止状态日志接口 [0.5/3]
- remote_app.go: serves keyed per workspace; StopServer/ServerStatus/
  ServerLogs take hostID+workspace; per-host registry map + serve entries
- remote_window/lifecycle tests; RemotePanel + remote-store + bridge
  bindings and mocks updated to the per-workspace signatures
2026-08-24 14:15:48 +08:00
XTLine 42a962d194 feat(desktop): remote project registry and tree groups / 远程项目注册与项目树分组 [0.5/3]
- remote_projects.go: per-host remote project registry with config pinning
- session_catalog.go: remote tree nodes; tabs.go: WorkspaceTab.Remote ref
- app.go: registry bootstrap wiring
- ProjectTree: remote group rows with cloud badge, serve session rows
  and remote menu (opens/stop/unpin); session click lands with [1/3]
- bridge/types: RemoteTabRefView + OpenRemoteProjectTab bindings
2026-08-24 13:49:01 +08:00
XTLine c57f592693 chore(desktop-fe): ratchet bundle budgets for the connect wizard / 连接向导预算 ratchet [0/3]
- initial JS 431.6→432.5, app-shell CSS 114.3→115.4, zh 56.5→57.2,
  zh-TW 57.2→58.0, raw startup 2353.2→2364.0 (measured at [0/3])
2026-08-24 13:41:45 +08:00
XTLine cc69bbe133 feat(desktop): wizard-level remote platform check / 连接向导阶段的远程平台检查 [0/3]
- remoteKernel.CheckPlatform: uname through the same ParseUname gate
  EnsureServe applies; App.CheckRemotePlatform binding for the wizard
- wizard awaits the check at the connecting step, before directory
  browsing, failing early with one clear message on unsupported OS
- bridge binding + mock; platform-check tests (app/lifecycle)
2026-08-24 13:39:19 +08:00
XTLine 514cbc9e6a feat(config): pin remote projects in user config / 用户配置支持固定远程项目 [0/3]
- config/remote.go: pinned remote project entries (host + workspace)
- render.go: render pinned projects; remote_config_test coverage
2026-08-24 13:14:57 +08:00
XTLine 7462994451 feat(desktop-fe): three-step remote connection wizard / 三步远程连接向导 [0/3]
- RemoteConnectWizard: host pick → auth/platform check → workspace
  browse, all-or-nothing finish, connect log rail, z-index tokens
- add-project menus offer remote connect (ProjectTree + useProjectCreation)
- bridge/types: remote tab bindings with browser mocks
- locales en/zh/zh-TW wizard keys; wizard styles
2026-08-24 13:14:53 +08:00
zhongerdeajie 99d5c4323b fix(windows): restore taskbar icon on the running desktop window
Wails v2 registers its window class (wailsWindow) without an hIcon, so the
window answers WM_GETICON with NULL. On Windows 11, once the process sets an
explicit AppUserModelID (PR #7925 -> SetCurrentProcessExplicitAppUserModelID
Reasonix), the taskbar prefers resources registered for that AUMID over the
executable's embedded icon; with none registered it renders the blank-document
glyph for the running window's button. Pinned shortcut icons are unaffected
(they read IconLocation -> reasonix-launcher.exe).

Set the first icon group from the running executable on the Wails window and
its class at startup: WM_SETICON (big+small) plus SetClassLongPtr(GCLP_HICON,
GCLP_HICONSM), which also covers Alt+Tab and window switcher rendering.
Loads via ExtractIconExW, retrying briefly until the wailsWindow appears.

Verified: go vet ./... and unit tests in desktop; plus an isolated Win32 A/B
repro (no-hIcon window class + explicit AUMID) showing the taskbar glyph goes
from blank-document to the embedded brand icon (taskbar screenshot saturation
0.456 -> 0.742).

Fixes the taskbar-icon half of the Windows desktop regression; grouping is
already handled by PR #7925.
2026-08-24 12:15:16 +08:00
SivanCola 6e4b9c9be3 test: split provider group removal coverage
Problem:
The grouped-removal regression coverage pushed an existing desktop test file beyond the repository size ceiling.

Root cause:
The new OpenCode Go tests were added to the already-large general provider-removal test file.

Fix:
Move the new grouped custom-provider tests into a focused test file without changing their behavior.

Verification:
- go run ./tools/repolint
- cd desktop && go test ./...
- focused grouped-removal tests
- focused grouped-removal tests under go test -race
2026-08-24 10:46:22 +08:00
SivanCola 8b7af4d7ab fix: remove grouped custom provider routes atomically
Problem:
The OpenCode Go settings card submitted all of its custom routes for removal, but the backend rejected every multi-provider custom request.

Root cause:
Grouped deletion was restricted to official provider aliases even though OpenCode Go is presented as one product-level connection backed by several custom provider entries.

Fix:
Recognize the narrow OpenCode Go route family, expand stale requests to every installed family member, and reuse the failure-atomic provider removal and runtime retargeting path for the full group. Unrelated custom batches remain rejected.

Verification:
- go test ./... (desktop module)
- go test -race focused grouped removal tests
- provider access card component test
- frontend production and test TypeScript checks
2026-08-24 09:40:44 +08:00
SivanColaandhanswu 0bba64a9cd fix: restore explicit readiness recovery and stabilize transcript scroll / 恢复显式收尾恢复并稳定会话滚动 (#9341)
* fix(control): restore explicit readiness recovery

Problem: Standard and Delivery turns could launch hidden readiness continuations after a final answer, causing extra model rounds and useless work.\n\nRoot cause: ordinary orchestration armed automatic readiness continuation and classified task progress from per-turn control state and assistant prose.\n\nFix: stop ordinary Standard and Delivery turns at the readiness boundary, preserve explicit Delivery recovery and Goal/Plan continuation, and remove the task-progress continuation path and prose heuristic.\n\nVerification: go test ./internal/agent ./internal/control

* fix(desktop): stop freezing transcript rows during scroll

Problem: manual transcript scrolling could freeze recycled rows to estimated heights and maintain a second pending-Markdown geometry contract, allowing Virtuoso offsets and painted content to diverge.

Root cause: the scroll arbiter forced DOM height during measurement and the geometry cache calibrated unseen rows from unrelated samples.

Fix: keep real measurements for wheel and touch reading, remove pending Markdown fixed-height fallbacks, remove cross-row calibration, and route viewport-shrink tail pinning through the existing Virtuoso writer.

Verification: transcript measurement, native scrollbar, scroll-release, anchor-compensation, recovery-race, typecheck, and single-scroll-writer checks.

* fix(desktop): bound transcript tail writer

* test(desktop): align transcript tail writer fixtures

* docs: document explicit readiness recovery

* fix(desktop): restore explicit Delivery recovery copy

* test(desktop): clarify native scrollbar measurement contract

* fix(desktop): stabilize async markdown geometry

* fix(desktop): keep native scrollbar drag geometry stable

* fix(desktop): adopt bounded transcript scroll recovery

Problem: tail settling could re-arm on sub-row measurement jitter, and a near-bottom downward wheel could arm extent recovery that fought the user's scroll. The previous CI desktop failure also lacked enough geometry evidence and Windows had no browser replay of the transcript path. Legacy readiness comments still described removed ordinary-turn continuation.

Root cause: the tail writer had an attempt cap but no height-growth gate or pinned-state distinction, while releaseTailFollow armed reader extent unconditionally. The browser harness spawned pnpm with a POSIX-only command name and the CI contract counted transcript checks across jobs.

Fix: add a single 24px tail re-arm policy that only suppresses writes while the tail is actually pinned, keep displaced tail recovery live, add the #9248 near-bottom guard, and cover both in deterministic tests. Make transcript browser benches cross-platform, add a Windows Chromium replay beside the native WebView2 smoke, improve timeout diagnostics, and clarify Standard/Delivery readiness comments without changing wire fields.

Verification: go test ./...; go test -race ./internal/agent ./internal/control; cd desktop && go test ./...; cd desktop/frontend && pnpm test:transcript; pnpm test:transcript-browser (two stability runs); pnpm test:motion; pnpm typecheck; pnpm check:scroll-writer; pnpm build; go vet ./internal/agent ./internal/control; go run ./tools/repolint ./....

Co-authored-by: hanswu <[email protected]>

* fix(desktop): launch transcript preview on Windows

Problem: the Windows transcript replay reached the browser bench but failed before starting Vite with spawn EINVAL under Git Bash.

Root cause: Windows command shims such as pnpm.cmd need shell dispatch when launched by Node child_process on the runner.

Fix: keep the platform-specific pnpm command and enable shell dispatch only on Windows for both transcript browser benches.

Verification: cd desktop/frontend && pnpm test:transcript-browser; the Linux/macOS path remains direct spawn and the Windows CI job will rerun the replay.

* test(desktop): make native thumb endpoint platform aware

Problem:\nThe transcript browser replay passed its selection and geometry assertions on Windows but timed out while dragging the native scrollbar thumb to the physical bottom.\n\nRoot cause:\nWindows Chromium keeps a held native scrollbar thumb attached to the track, while Linux themes clamp after an overshoot beyond the track. A single overshoot endpoint is not portable.\n\nFix:\nUse the in-track endpoint on Windows and retain the Linux overshoot endpoint. The gate still exercises native thumb ownership, measurement freezing, release, and tail remeasurement.\n\nVerification:\n- cd desktop/frontend && node bench/transcript-scroll-stability.mjs\n- git diff --check

* test(desktop): skip unsupported Windows headless thumb drag

Problem:\nHeadless Chromium on Windows reports the reserved scrollbar gutter but does not expose a draggable native thumb, so the transcript replay timed out after all geometry and scroll assertions had passed.\n\nRoot cause:\nThe browser benchmark was asserting a WebView2/native scrollbar capability that is unavailable in the headless Chromium runner.\n\nFix:\nSkip only the native thumb drag portion on Windows unless explicitly enabled with REASONIX_TRANSCRIPT_NATIVE_THUMB=1. Keep the complete replay, Linux native-thumb gate, transcript-native-scrollbar unit coverage, and Windows WebView2 native smoke.\n\nVerification:\n- cd desktop/frontend && node bench/transcript-scroll-stability.mjs\n- git diff --check

---------

Co-authored-by: hanswu <[email protected]>
2026-08-24 09:03:22 +08:00
SivanCola 2d40271a5e fix(desktop): allow deleting configured default providers / 修复已配置默认模型服务无法删除 (#9344)
* fix(desktop): allow deleting configured default providers

Problem: The provider access menu was disabled for any custom provider selected as the current default, so configured providers could not be deleted from settings.\n\nRoot cause: The frontend blocked the action based on default-model state even though the backend already retargets references to a configured fallback and rejects unsafe removals.\n\nFix: Remove the frontend-only default-provider deletion gate and keep the backend as the source of truth. Add a regression test covering the custom provider removal confirmation flow.\n\nVerification: provider access card test, frontend typecheck, test typecheck, ESLint, CSS checks, and desktop Go tests all pass.,

* test(desktop): cover deleting the current default custom provider

Problem: the provider deletion regression test did not model a custom provider selected as the current default. Root cause: the card-only fixture bypassed SettingsPanel and never supplied a defaultModel reference. Fix: render the real models/access settings path with a default custom provider and verify the delete action reaches RemoveProviderAccesses. Verification: provider-access-card test, test typecheck, frontend typecheck, targeted ESLint, CSS checks, and settings-refresh snapshot test all pass.
2026-08-24 08:58:44 +08:00
SivanCola 2fae7e1b16 fix: preserve sidebar activity order during exact indexing / 修复精确索引导致的侧栏会话错排 (#9314)
* fix: keep runtime conversation rows in canonical order

Problem: Switching conversations could reorder sidebar rows while runtime and catalog snapshots crossed.

Root cause: The frontend sort comparator treated runtime session rows as incomparable with catalog topic rows, so async arrival order leaked into the visible order.

Fix: Sort both row shapes with the same pinned, manual, activity, and stable-key rules, while keeping non-conversation nodes deterministic. Add a regression test covering a runtime-only row ahead of catalog rows.

Verification: Focused project-tree runtime, catalog-completeness, and runtime-projection tests; frontend typecheck.

* test: keep project tree regression within file budgets

Problem: The focused regression pushed project-tree-runtime.test.ts past the repository test-file budget.

Fix: Move the sorting regression into the dedicated project-tree-sort-runtime test module without changing production behavior.

Verification: Project-tree runtime tests, repolint, and frontend typecheck.

* fix(sessioncatalog): preserve recovery metadata during index repair

Problem: the first PR revision failed repository standards and could leave recovery metadata stale when an unknown count record arrived for an existing session.\n\nRoot cause: the exact-index fast path was embedded in the large upsert function, and its downgrade guard skipped the whole record without distinguishing count fields from recovery metadata.\n\nFix: extract exact-path preparation into a focused helper, preserve known counts only when the content fingerprint is unchanged, and still apply recovery metadata updates.\n\nVerification: go run ./tools/repolint; go test ./internal/sessioncatalog -count=1; go test -race ./internal/sessioncatalog -count=1; go vet ./internal/sessioncatalog; cd desktop && go test ./... -run 'Test(DeleteSession|SessionCatalog|ProjectTree|Reconcile|ListProjectTopics)' -count=1

* fix: preserve activity during exact session indexing

Problem: A transient older sidecar read could overwrite a known newer activity timestamp and reorder the project sidebar while switching conversations.

Root cause: Exact-path indexing replaced the catalog activity projection without comparing it with the existing authoritative content projection.

Fix: Preserve the known activity timestamp when the transcript fingerprint is unchanged and the incoming sidecar timestamp is older. Add a deterministic regression test for the backwards-activity case.

Verification: Focused sessioncatalog tests, project-tree runtime/sort tests, frontend typecheck, repolint, and git diff --check.

* refactor: remove unreachable runtime session sort path

Problem: The frontend sorter change targeted top-level session rows that the current runtime projection does not emit.

Fix: Remove the unreachable presentation change and its misleading fixture so the PR owns only the exact-index activity regression that reproduces the reported sidebar reorder.

Verification: Project-tree tests, sessioncatalog tests, repolint, and frontend typecheck.
2026-08-24 00:05:01 +08:00
SivanCola a33553b0c4 fix(sessioncatalog): stop repeated repair reindex loop / 修复会话目录反复重新索引 (#9312)
* fix(sessioncatalog): stop repeated repair reindex loop

Problem: Idle desktop sessions could repeatedly show indexing progress and make sidebar deletion appear unavailable.\n\nRoot cause: Exact-path catalog indexing rewrote an unchanged projection and published a new revision on every defensive snapshot, allowing repair and reconcile work to feed back into one another.\n\nFix: Skip unchanged exact-path projections, prevent stale unknown inputs from downgrading known counts, and add regression coverage for repeated indexing and repair completion.\n\nVerification: go test ./internal/sessioncatalog -count=1; go test -race ./internal/sessioncatalog -count=1; go vet ./internal/sessioncatalog; cd desktop && go test ./... -run 'Test(DeleteSession|SessionCatalog|ProjectTree|Reconcile|ListProjectTopics)' -count=1

* fix(sessioncatalog): preserve recovery metadata during index repair

Problem: the first PR revision failed repository standards and could leave recovery metadata stale when an unknown count record arrived for an existing session.\n\nRoot cause: the exact-index fast path was embedded in the large upsert function, and its downgrade guard skipped the whole record without distinguishing count fields from recovery metadata.\n\nFix: extract exact-path preparation into a focused helper, preserve known counts only when the content fingerprint is unchanged, and still apply recovery metadata updates.\n\nVerification: go run ./tools/repolint; go test ./internal/sessioncatalog -count=1; go test -race ./internal/sessioncatalog -count=1; go vet ./internal/sessioncatalog; cd desktop && go test ./... -run 'Test(DeleteSession|SessionCatalog|ProjectTree|Reconcile|ListProjectTopics)' -count=1
2026-08-23 23:00:25 +08:00
github-actions[bot] b9cf32f81f docs(release): prepare v1.31.3 notes / 准备 v1.31.3 更新日志
Summary:
Prepare the reviewed bilingual Stable release catalog for v1.31.3.

Verification:
Release-notes catalog validation, rendered notes review, and PR checks passed.
2026-08-22 13:17:36 +08:00
SivanCola ffe9885f5a Merge pull request #9242 from SivanCola/feature/official-deepseek-vision-exp
feat: add official DeepSeek vision SKU user-image support / 接入官方 DeepSeek 视觉模型用户贴图
2026-08-22 13:06:05 +08:00
SivanCola 41b5b5a0e0 fix: remove obsolete desktop vision helper
Problem: the latest desktop merge no longer calls imageInputEnabledForRootModel, so macOS golangci-lint reports it as unused.\n\nRoot cause: MetaForTab now reads cached tab metadata refreshed by the background path.\n\nFix: remove the obsolete duplicate lookup and retain the shared root config loader used by metadata refresh.\n\nVerification: cd desktop && go test ./... -run 'TestMeta|TestTabMeta|TestImage' -count=1; golangci-lint run --timeout=5m.
2026-08-22 12:40:44 +08:00
SivanCola 12bb4a9bc1 fix: satisfy modern vision summary lint
Problem: the cross-provider file-id guard used a manual loop that the repository's modernize lint rejects.\n\nRoot cause: the merged main-v2 lint configuration requires slices.ContainsFunc for this predicate.\n\nFix: replace the loop with slices.ContainsFunc while preserving the fail-closed cross-provider validation.\n\nVerification: go test ./internal/control -run 'Vision|Image' -count=1; go vet ./internal/control; go run ./tools/repolint.
2026-08-22 12:25:59 +08:00
SivanCola 021cd890d6 Merge origin/main-v2 into vision fallback PR
Resolve the latest main-v2 bundle-budget and repolint baseline conflicts while retaining the text-only image-understanding fallback behavior. Re-run root and desktop tests, frontend build and vision regression tests after the merge.
2026-08-22 12:19:37 +08:00
SivanCola aa82b2f94f Cleaned web search sources panel / 清理搜索来源面板 (#9264)
* fix(desktop): present cleaned web search sources

Problem: desktop search answers rendered raw provider URLs directly below model text, including long redirect links.

Root cause: history and live search paths stored source hits but reused a Markdown footnote renderer, while web_search cards exposed raw output.

Fix: add a display-only source projection that validates HTTP(S), unwraps known redirects, removes tracking parameters, deduplicates hits, and hides invalid/titleless sources. Render a collapsed accessible source panel and structured web_search summary for both live and historical sessions without changing replay payloads.

Verification: focused frontend source/panel/tool tests, frontend typechecks, CSS checks, lint, and Go provider/agent/cli tests pass. The production bundle build completes; the existing gzip budget is 0.8 KiB over its 430.9 KiB gate.

* test(desktop): avoid URL substring security alert

Problem: CodeQL flagged the render regression test for asserting URL visibility with a host substring.

Root cause: substring-based URL checks can be bypassed by arbitrary surrounding hosts and are treated as incomplete sanitization.

Fix: assert the collapsed panel does not mount its source-details body instead of matching a URL substring.

Verification: search sources panel test, frontend typecheck, and git diff --check pass.

* fix(desktop): keep controller within repo size budget

Problem: repository standards rejected the PR because the controller file crossed its existing file-size budget.

Root cause: the display-only web-search state added two net lines to an already budget-bound reducer.

Fix: keep the new display field and tool-id fallback behavior while expressing them within the existing owner-file line budget.

Verification: repolint, frontend lint, typecheck, focused search tests, and git diff --check pass.

* perf(desktop): lazy-load source panel

Problem: the initial desktop bundle exceeded the 430.9 KiB gzip gate after adding source-panel UI.

Root cause: the new panel was statically reachable from the large Message module, so its display code and icon dependency were included in the startup chunk.

Fix: move the source-visibility predicate into the existing search-source utility and lazy-load the panel from Message, while retaining a compatibility wrapper for the legacy component name.

Verification: frontend lint, typecheck, focused search render/source tests, repolint, and git diff --check pass.

* perf(desktop): bound source panel bundle cost

Problem: the cleaned web-search presentation pushed the startup and locale bundle gates over their existing limits.\n\nRoot cause: source normalization and the added panel copy/styles were included in the initial application path.\n\nFix: move normalization behind the source panel boundary, keep tool-card counts lightweight, and add narrow attributable bundle allowances for the UI payload.\n\nVerification: go run ./tools/repolint; pnpm build; pnpm typecheck; focused search source, panel, and tool-card tests.

* chore(desktop): retain bundle budget headroom

Problem: main-v2 moved the deferred app-shell and locale assets onto the existing rounded thresholds.\n\nRoot cause: the base branch changed the measured startup payload while this PR also adds the source presentation UI.\n\nFix: preserve explicit, narrow headroom for the measured CSS and Chinese locale chunks.\n\nVerification: pnpm build; go run ./tools/repolint; focused frontend tests.

* fix(desktop): retain source panel locale keys

Problem: the source panel contract defines count and no-valid localization keys.\n\nRoot cause: bundle trimming removed keys that remain part of the display contract.\n\nFix: restore the English, Simplified Chinese, and Traditional Chinese source labels while keeping the lazy presentation boundary.\n\nVerification: pnpm build.

* fix(desktop): share cleaned counts with search cards

Problem: the web-search card initially counted raw hits while the assistant panel counted cleaned sources.\n\nRoot cause: keeping normalization out of the startup path left the tool card without the display projection.\n\nFix: resolve the presentation module lazily in the tool card, fall back to the raw count during loading, and show the filtered/hidden counts once loaded.\n\nVerification: pnpm build; pnpm typecheck; focused panel and tool-card tests; go run ./tools/repolint.
2026-08-22 03:11:34 +00:00
SivanCola 3ebf6126fb chore: refresh repo lint baseline
Refresh deliberate carry-forward limits for the vision settings and metadata changes while keeping the existing repository standards gate strict.
2026-08-22 11:00:19 +08:00
SivanCola 925b2eabbb fix: suppress obsolete image fallback warning
Problem: the composer warned that text-only models could not inspect images even when the configured vision fallback was already analyzing the attachment.\n\nRoot cause: the frontend only received the primary model's image capability and could not distinguish direct-image support from configured image-understanding preprocessing.\n\nFix: expose the cached vision-fallback capability in session metadata, preserve it across tab updates, suppress the warning when fallback is configured, and update the remaining warning copy to point users to Settings. Refresh metadata immediately after settings changes.\n\nVerification: desktop targeted Go tests; frontend typecheck and test:typecheck; composer image capability tests (17/17); use-controller meta tests (163/163); production frontend build; git diff --check.
2026-08-22 10:52:00 +08:00
SivanColaandFuchaZ e1ea25e88b fix(desktop): prevent transcript flicker during session switches / 修复切换会话时 transcript 闪烁 (#9266)
* fix(desktop): prevent transcript flicker during session switches

Problem: switching sessions briefly rendered the target tab's empty transcript while history hydration and tail-follow writes were still in flight.

Root cause: navigation masking cleared visible items before the target surface was ready, automatic older-history paging amplified layout churn, and recycled Virtuoso rows reused stale known sizes.

Fix: retain the last stable transcript behind an inert loading overlay, commit the target only after hydration, suppress initial auto-completion and hydration-time tail-follow, and ignore recycled-row geometry as a current contract.

Verification: pnpm typecheck; pnpm test:transcript; navigation-surface-transition.test.ts; pnpm build; CSS, WAAPI, scroll-writer, and bundle-budget checks.

* chore: update repolint baseline for transcript surface

* fix(desktop): guard stale hydrate failure rollback

* chore: refresh repolint baseline

Co-authored-by: FuchaZ <[email protected]>
2026-08-22 10:40:00 +08:00
SivanCola 67983354d5 fix: match vision picker settings menu
Problem: the image-understanding model setting used a native select, so its popup inherited the operating system dark menu instead of matching the settings model picker.\n\nRoot cause: the field bypassed the shared grouped model-picker component used by the surrounding model settings.\n\nFix: route vision model selection through the shared picker, preserving none/auto choices while inheriting the light card menu, provider groups, search, selected state, and accessibility behavior.\n\nVerification: pnpm typecheck; pnpm test:typecheck; pnpm test:settings-responsive; pnpm build.
2026-08-22 10:20:19 +08:00
SivanCola 491a675720 fix: reserve output for vision summaries
Problem: reasoning-heavy vision models could consume the bounded completion budget before emitting visible image text.\n\nRoot cause: the summary prepass inherited the provider's default reasoning depth.\n\nFix: request the lowest supported per-call effort so the 2048-token budget remains available for OCR and layout text; providers without that vocabulary keep their configured behavior.\n\nVerification: go test ./internal/control ./internal/provider/openai ./internal/provider/anthropic ./internal/provider/responses.
2026-08-22 10:04:48 +08:00
SivanCola f229d2895f fix: migrate configured vision model refs
Problem: legacy OpenCode Go provider splits rewrote planner and subagent model references but left the new vision fallback reference stale.\n\nRoot cause: the model-reference migration helper did not include Agent.VisionModel.\n\nFix: rewrite the vision model reference alongside the other agent model settings and cover the migration with a regression test.\n\nVerification: go test ./internal/config.
2026-08-22 09:52:05 +08:00
SivanCola 53f11f5192 feat: add text and vision model fallback
Problem: text-only models could not understand attached images, while the desktop model settings had no way to configure a vision companion.

Root cause: image candidates were retained for vision-capable children but no main-turn prepass converted them into context for a text executor.

Fix: add agent.vision_model configuration and desktop settings, resolve same-provider automatic vision models, generate bounded hidden image summaries, persist and reuse summary metadata, and preserve direct-image behavior for vision-capable models.

Verification: go test ./..., cd desktop && go test ./..., go test -race ./internal/control ./internal/provider, and frontend typecheck/build with bundle budgets.
2026-08-22 09:48:20 +08:00
SivanCola b1f60c8138 fix(desktop): repair typecheck errors in transcript diagnostics tests (#9254)
Problem: pnpm test:typecheck fails on 3 type errors introduced by the
transcript-geometry test files (frontend-diagnostics.test.ts, transcript-row-geometry.test.ts).

Root cause: new test fixtures used Array.prototype.at (ES2022 lib not
enabled in tsconfig.test.json) and omitted required fields of TranscriptRow
(reasoning) and SubagentProgress (truncated). tsx strips types at runtime,
so CI (which does not run typecheck) stayed green.

Fix: index the last event via length, and add the two missing fixture fields.

Verification: pnpm test:typecheck passes; both test files pass under tsx.
2026-08-22 02:08:26 +08:00
github-actions[bot] 307617372a docs(release): prepare v1.31.2 notes (#9250)
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-22 00:30:42 +08:00
SivanCola 3b0d71cd67 fix(desktop): stabilize transcript geometry and add diagnostics / 修复桌面会话上滚闪回并增加前端诊断 (#9240)
* fix(desktop): stabilize transcript geometry and add diagnostics

Problem: Windows transcript upward scrolling could flash back when unseen collapsed reasoning and tool rows mounted with oversized estimates and asynchronous Markdown geometry. Test builds also lacked a single, exportable frontend event trace for diagnosing the failure.

Root cause: row rendering state, initial estimates, measured-size reuse, snapshot validity, and browser fallback geometry did not share one contract. Recycled rows could reuse stale measurements while browser scroll anchoring and Virtuoso measurement updates competed during a manual upward gesture.

Fix: add layout-variant-aware row geometry and estimates, session-scoped measurement LRU with width/typography/content validation, strict snapshot contracts, pending-Markdown geometry freezing, manual-reader measurement freeze, and browser regression fixtures. Add build-channel-gated frontend diagnostics with sanitized event export, session-tree counters, settings integration, and Windows build-channel propagation.

Verification: pnpm typecheck; pnpm test:transcript; pnpm test:transcript-browser; pnpm exec vite build; go test ./...; check-single-scroll-writer; desktop build contract; git diff --check.

* chore(desktop): keep diagnostics changes within repo lint budgets

Problem: the diagnostics integration pushed two existing frontend files past their ratcheted file-size budgets, and the local build left a numeric Wails version stamp behind.

Fix: extract the Virtuoso row/list/footer pieces and project-tree diagnostic emitter into focused modules, preserve the geometry fixture with readable mock data, restore the source Wails version, and baseline the one-line bridge fixture growth required by the browser contract.

Verification: pnpm typecheck; go run ./tools/repolint; git diff --check.

* fix(desktop): keep diagnostics panel out of startup bundle

Problem: the frontend diagnostics panel was statically imported in the transcript entry, pushing the initial JavaScript gzip over the desktop budget.

Fix: restore lazy loading for the opt-in diagnostics panel and record the bounded startup cost of the state-aware geometry and session diagnostic probes in the bundle gate. Stable builds keep the panel out of the initial UI while test and canary builds retain the visible entry point.

Verification: pnpm build; REASONIX_CHANNEL=test bundle budget check; pnpm typecheck; go run ./tools/repolint.

* test(desktop): settle transcript harness after rebasing main-v2

Problem: the rebased main-v2 branch exposed a timing-sensitive transcript harness and stale repolint thresholds. CI could assert before the lazy live region and rewind scroll had committed, while the merged base carried updated file metrics.

Fix: drain one additional render frame in the transcript harness and refresh the repolint baseline after resolving the main-v2 changes. This keeps the test contract deterministic without changing runtime scroll ownership.

Verification: pnpm typecheck; transcript virtualization 46 passed; go run ./tools/repolint; go test ./... .

* build(desktop): account for merged startup bundle drift

Problem: after rebasing onto the current main-v2, the deterministic initial JavaScript gzip measured 430.7 KiB and failed the 430.4 KiB gate.\n\nFix: record the 0.3 KiB shared startup drift explicitly with a 430.9 KiB bounded budget. The existing largest-chunk, raw-size, CSS, locale, typecheck, and lint gates remain unchanged.\n\nVerification: pnpm build.
2026-08-21 22:57:46 +08:00
SivanCola 9ff292dd79 fix: complete DeepSeek vision PR integration
Problem: PR #9242 was behind main-v2, failed lint and desktop assertions, and its Responses stateful shortcut dropped user images. Root cause: the continuation fast path sent only the last user Content while the new vision path stores images separately; the branch also lacked latest-base bundle-budget changes and updated model-count expectations. Fix: merge the latest main-v2 baseline, bypass text-only stateful continuation for image turns, modernize FieldsSeq tokenization, update official vision SKU assertions, and reconcile locale bundle budgets. Verification: go test ./...; cd desktop && go test ./...; go test -race ./internal/provider/responses; pnpm build; go vet ./...; go run ./tools/repolint.
2026-08-21 22:27:10 +08:00
SivanCola 65b32395da Merge pull request #9245 from SivanCola/feature/opencode-go-one-key-setup
feat(provider): simplify OpenCode Go setup / 简化 OpenCode Go 配置
2026-08-21 21:55:47 +08:00
SivanCola 528bd5fcfa fix: keep official DeepSeek vision CI green on Windows and repolint
Problem:
CI lint failed repolint file-size on refs.go, refs_test.go, and
anthropic.go. windows-control failed
TestResolveRefsVisionCapableImageDoesNotAskForOCR because the assertion
used filepath.Join separators.

Root cause:
Vision URL/file_id helpers landed in already-grandfathered files. The
OCR test compared an OS-native path against slash-normalized image
blocks.

Fix:
Move vision token/note helpers into vision_images.go and Anthropic
imageSourceFromRef into anthropic/image.go. Assert slash-normalized
attachment paths so Windows matches the stored ref form.

Verification:
go test ./internal/control ./internal/provider ./internal/provider/openai
./internal/provider/anthropic ./internal/provider/responses ./internal/config
File-size overages: refs.go 554/568, refs_test.go 141/141, anthropic.go 56/72.
2026-08-21 19:24:58 +08:00
SivanCola ad0e270abf Fix OpenCode preset setup review findings
Problem: Partial OpenCode bundles could not complete when a user-modified route left other routes missing, and preset setup saved credentials before the locked config conflict check. The output-limit test also violated the repository lint rule, while learned limits were only agent-local.\n\nRoot cause: The frontend treated every installed_modified state as terminal, AddProviderPresetAccess wrote the credential outside the config transaction, and output-budget observations had no provider/route/model-scoped TTL cache.\n\nFix: Allow modified partial bundles to add only missing routes, move credential persistence inside the config-then-credential lock order, add a 24-hour scoped output-budget cache with isolation tests, use strings.Builder in the retry test, and tighten the measured repolint baseline.\n\nVerification: go test ./internal/agent -count=1 -run 'Test(StreamProviderRequestRetriesOutputLimitBeforeAnyOutput|LearnedOutputBudgetCacheIsScopedToProviderRouteAndModel)' -v; pnpm exec tsx src/__tests__/provider-access-card.test.tsx; pnpm lint:hooks; pnpm typecheck; pnpm check:css; go run ./tools/repolint.
2026-08-21 19:24:06 +08:00
SivanCola 2a0d4415c6 fix: send official DeepSeek vision images in documented formats
Problem:
Dogfood of the vision SKU showed a read-only "no image input" badge,
and the agent OCR'd pasted pictures instead of using native vision.
CI also failed the zh-TW locale gzip gate on the SKU toast string.

Root cause:
Official DeepSeek Settings forced visionCapability=unsupported, so the
model picker hid image-input checkboxes. Attachment notes always told
the model to OCR a local path. Serializers only emitted base64 data
URLs, not http(s) image URLs or Files API file_id.

Fix:
- Settings uses the same per-model image-input checkboxes as other
  providers; runtime still refuses Flash/Pro image payloads.
- When vision is on, attachment notes say look at the image; do not OCR.
- Serialize user-turn images as inline base64, http(s) URLs, or Files
  API file_id. Official DeepSeek auto-uploads local images over 32 MiB.
- Raise the zh-TW locale gzip budget by 0.2 KiB (0.35%) for the vision
  SKU toast instead of shortening the recovery instruction.

Verification:
go test ./internal/config ./internal/provider ./internal/provider/openai
./internal/provider/anthropic ./internal/provider/responses ./internal/control
desktop TestProviderViewKeepsOfficialDeepSeekVisionSelection and
TestSaveProviderPersistsOfficialDeepSeekVisionModels; frontend
provider-editor-model-picker tests.
2026-08-21 19:15:46 +08:00
SivanCola 8bdea533de chore: account for OpenCode Go setup checks
Problem: the repository lint baseline rejected the intentionally expanded provider setup and regression coverage files.

Root cause: the new one-key setup flow adds localized UI, provider metadata, budget handling, and focused tests whose measured sizes exceed the previous per-file and aggregate baselines.

Fix: update only the affected file-size and test-file-size entries and the aggregate test capacity to the observed post-change values; leave unrelated historical baseline entries untouched.

Verification: go run ./tools/repolint; git diff --check
2026-08-21 19:00:22 +08:00
SivanCola f79da56f62 feat: simplify OpenCode Go provider setup
Problem: OpenCode Go routes and DeepSeek alternatives were presented as a dense protocol-level list, while oversized completion budgets could still trigger provider 400 errors.

Root cause: provider presets, setup UI, and request budgeting were modeled independently, so the one-key path did not express a safe default or route-specific limits.

Fix: group OpenCode Go with DeepSeek as featured providers, make one-key setup install the safe base routes, keep legacy and optional routes discoverable, preserve preset compatibility, and adapt completion budgets to route/model server limits.

Verification: pnpm exec tsx src/__tests__/provider-access-card.test.tsx; pnpm typecheck; pnpm check:css; pnpm lint:hooks; pnpm build; cd desktop && go test .; go test ./...
2026-08-21 18:30:39 +08:00
SivanCola b1091fdd04 feat: add official DeepSeek vision SKU user-image support
Problem:
Official DeepSeek now publishes deepseek-v4-flash-vision-exp with image
input, but Reasonix treated every official DeepSeek endpoint as
text-only and stripped user images.

Root cause:
CanConfigureVision, EffectiveVision, and the OpenAI, Anthropic, and
Responses serializers all disabled vision on api.deepseek.com regardless
of model. Stock catalogs also lacked the new SKU and Flash-identical
price card.

Fix:
Pin image input to deepseek-v4-flash-vision-exp. Backfill stock official
catalogs, copy Flash peak/off-peak prices, serialize user-turn images
only on that SKU, keep Flash/Pro string-only, and omit tool screenshots
on official DeepSeek. Settings shows a read-only image badge; composer
toasts name the vision SKU.

Verification:
go test ./internal/config ./internal/billing ./internal/provider/openai
./internal/provider/anthropic ./internal/provider/responses ./internal/boot
desktop focused tests; frontend composer and model-picker tests.
2026-08-21 18:21:04 +08:00
Linearleaf e9933eee99 fix(config): relax compact_ratio floor from 0.65 to 0.30
Allow users to set a lower compaction threshold for large context
windows (e.g. 1M). A lower threshold triggers compaction earlier,
giving users finer control over cost vs. context freshness tradeoffs.

The existing safety guards (Prepare boundary, foldEconomics) remain
unchanged — lowering the floor only expands the user-configurable
range, not the automatic behavior.

Fixes #9060
2026-08-20 16:27:54 +08:00
Xingbo Zhao 32fd8b4410 fix(desktop): boxed frame collapse
Fix \boxed rendering so the frame is a proper rectangle instead of a
collapsed vertical bar in WKWebView, and pin pnpm to v10 as required by
the frontend's engines field.

- KaTeX draws \boxed's frame as an empty .fbox span whose width comes
  from .katex-stretchy's width:100%. Under WKWebView that percentage
  resolves against the span's intrinsic width (0) inside the
  inline-table/table-layout:fixed vlist, so top/bottom borders vanish
  and left/right borders merge into one tall bar. Override the width
  with -webkit-fill-available, which fills the containing row while
  keeping the span's inline-block baseline alignment (vertical position
  intact).
- Add "packageManager": "[email protected].5" so Corepack/Wails use pnpm 10,
  matching the ">=10 <11" engine range, instead of defaulting to pnpm 11.
- TUI path: teach internal/cli/latex.go \boxed (render inner content),
  \left./\right. (invisible dot delimiter), and \vert/\lvert/\rvert/
  \Vert/\lVert/\rVert symbols, with regression cases in latex_test.go.
2026-08-19 23:24:16 +08:00
697 changed files with 66020 additions and 10032 deletions

No files matched your search

+27 -1
View File
@@ -394,7 +394,7 @@ jobs:
pnpm --dir frontend test:motion-browser
pnpm --dir frontend test:composer
pnpm --dir frontend test:transcript
pnpm --dir frontend test:transcript-browser
xvfb-run -a env REASONIX_TRANSCRIPT_NATIVE_THUMB=1 pnpm --dir frontend test:transcript-browser
- name: Test usage statistics frontend
run: pnpm --dir frontend test:usage-stats
@@ -540,10 +540,36 @@ jobs:
- name: Test native motion contracts
run: pnpm --dir frontend test:motion
# Chromium is the closest deterministic browser replay available on the
# Windows runner; the native smoke below separately exercises the actual
# installed WebView2 runtime. Keep both paths so transcript geometry is
# covered before the shell-level smoke starts.
- name: Test transcript browser replay on Windows
timeout-minutes: 10
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/desktop/frontend/.pw-browsers
run: |
pnpm --dir frontend exec playwright install chromium
pnpm --dir frontend test:transcript-browser
- name: Test WebView2 native smoke state machine
shell: pwsh
run: ../scripts/test-webview2-native-smoke.ps1 -SelfTest
- name: Test WebView2 transcript selection compositor
shell: pwsh
timeout-minutes: 5
run: ../scripts/test-transcript-selection-webview2.ps1 -Iterations 3
- name: Upload WebView2 transcript selection evidence
if: failure()
uses: actions/upload-artifact@v7
with:
name: webview2-transcript-selection-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/reasonix-transcript-selection-smoke
if-no-files-found: warn
retention-days: 14
# Build the real Wails executable on every desktop PR and exercise the
# exact production startup path in Microsoft's installed WebView2 runtime.
# The frontend and bindings were already built above, so this skips only
+103
View File
@@ -7,6 +7,22 @@ on:
- 'workers/crash-report/**'
- '.github/workflows/deploy-crash-worker.yml'
workflow_dispatch:
inputs:
firebase_data_action:
description: Firebase crash history operation (migration actions do not deploy the Worker)
required: true
default: none
type: choice
options:
- none
- dry-run
- apply
- verify-only
confirmation:
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
required: false
default: ''
type: string
permissions:
contents: read
@@ -17,6 +33,7 @@ concurrency:
jobs:
deploy:
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -36,6 +53,28 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:diagnostics-v2
- name: Apply and verify Firebase crash D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:firebase-crash
- name: Sync Firebase crash secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
run: |
configured=0
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
echo "Firebase crash secrets must be either all configured or all absent."
exit 1
fi
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
- name: Deploy
working-directory: workers/crash-report
env:
@@ -58,3 +97,67 @@ jobs:
exit 0
fi
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
migrate-firebase-data:
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
runs-on: ubuntu-latest
environment: canary
steps:
- uses: actions/checkout@v7
- name: Require the protected production branch
run: |
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
echo "Firebase crash data migration must run from main-v2."
exit 1
fi
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Run Firebase crash history operation
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
run: |
configured=0
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 4 ]; then
echo "Cloudflare and Firebase migration secrets must all be configured."
exit 1
fi
case "$FIREBASE_DATA_ACTION" in
dry-run)
npm run migrate:firebase-data
;;
apply)
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
exit 1
fi
npm run migrate:firebase-data -- --apply
npm run migrate:firebase-data -- --verify-only
;;
verify-only)
npm run migrate:firebase-data -- --verify-only
;;
*)
echo "Unsupported Firebase crash data action."
exit 1
;;
esac
+3
View File
@@ -4,9 +4,12 @@ on:
pull_request:
paths:
- "release-notes/**"
- "scripts/generate-release-notes.mjs"
- "scripts/release-notes*.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "site/src/data/releases.ts"
- "site/src/styles/changelog.css"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
+2
View File
@@ -36,6 +36,8 @@ mem.prof
.env
.env.local
*.local
**/.firebase-crash-migration-state.json
**/.firebase-crash-migration-state.json.tmp-*
/reasonix.toml
/desktop/.env
/desktop/reasonix.toml
+20
View File
@@ -20,6 +20,26 @@ branch.
### Fixed
- **Compact MCP discovery:** `use_capability(action=list)` now returns one
compact summary per configured MCP server instead of expanding every cached
tool description, including tools from disabled servers. Inspecting one
enabled `mcp-server:<name>` still returns its live or cached directory
without starting it, while direct known-ID calls, routing, authorization,
and the fixed provider-visible tool schema remain unchanged.
- **Project MCP session reliability:** The MCP client now uses the official Go
SDK for stdio, legacy SSE, and Streamable HTTP while retaining Reasonix's
existing configuration, OAuth, process isolation, and schema-cache contracts.
Streamable HTTP opens its long-lived GET/SSE listener immediately after
initialization, so JetBrains project-level `.mcp.json` servers no longer lose
their pending session before the first tool call. Lost sessions converge on
one bounded rebuild and one replay, read-only surfaces consume every cursor
page, prompts/resources share the tool session, and shutdown terminates HTTP
sessions and local processes. MCP calls also accept a single JSON-object
string in `use_capability.arguments`, while rejecting arrays, scalars, invalid
JSON, and nested encoded strings. `/mcp` and Desktop expose redacted protocol,
listening, reconnect, and error-category diagnostics without session IDs.
- **v1.24.2 session snapshot & recovery root fix:** Keep PR #7982's WAL/CAS/lease
safety foundation, but replace process-level "I hold a lease" ownership with a
generation-bound `SessionWriteAuthority`. Same-revision tool-preview/load
+2
View File
@@ -8,6 +8,7 @@ import (
"reasonix/internal/cli"
"reasonix/internal/config"
"reasonix/internal/crashreport"
"reasonix/internal/plugin"
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/provider/anthropic"
@@ -36,6 +37,7 @@ var runCLI = func(args []string, buildVersion string) int {
}
func main() {
plugin.SetMCPClientVersion(version)
os.Exit(runWithCrashCapture(os.Args[1:], version))
}
+127 -153
View File
@@ -127,6 +127,11 @@ type PromptHistoryResult struct {
type App struct {
ctx context.Context
workspaceHub *workspaceChangeHub
topicState *topicStateManager
// topicTitleMutationMu keeps the authoritative title commit and its Tab /
// session-sidecar publication in the same order for manual and automatic
// renames. It is never held by generic topic-state reads or other metadata.
topicTitleMutationMu sync.Mutex
// sessionCatalog is a disposable, asynchronously opened projection of
// authoritative session sidecars. Project-shell APIs must tolerate nil here:
@@ -165,6 +170,9 @@ type App struct {
tabOrder []string
activeTabID string
readyHook func()
// tabSelectionMu serializes cross-registry activation. A remote selection
// must not overtake the local-session snapshot that makes switching safe.
tabSelectionMu sync.Mutex
// Ticketed topic activation bookkeeping (StartTopicActivation). Guarded by
// mu. activationGen bumps on every activation-or-supersede so a background
@@ -335,6 +343,22 @@ type App struct {
remoteWindows *remoteWindowRegistry
remoteWindowLifecycles remoteWindowLifecycleRegistry
remoteWindowOpener func(remoteWindowLaunch) error // test-only injection
// Remote project tabs are in-app surfaces bound to a remote workspace.
// Project pins persist in user config; open tab shells persist separately
// and restore disconnected until the user activates them.
remoteTabMu sync.Mutex
remoteTabs map[string]*remoteTab
remoteTabLayout remoteTabLayoutState
remoteTabTasks sync.WaitGroup
// remoteTabModelMu makes the caller's current-model snapshot, the remote
// Serve rebuild, and the tab metadata commit one transaction. Without it,
// overlapping switches could roll remote config back to a stale model.
remoteTabModelMu sync.Mutex
// remoteEventHook observes remote events in tests; production leaves it nil.
remoteEventHook func(name string, payload any)
// credProxy is the lazy app-wide key holder for local-proxy mode.
credProxyMu sync.Mutex
credProxy *credentialProxy
// remoteWindowTicket/remoteWindowHostKey are set from argv before Wails
// starts in a child process. They gate the blank-shell middleware and the
// startup branches so the child never initializes local runtimes.
@@ -422,6 +446,7 @@ func NewApp() *App {
botRuntime: newDesktopBotRuntime(),
remoteWindows: newRemoteWindowRegistry(),
remoteWindowOwnerID: newRemoteWindowOwnerID(),
topicState: desktopTopicState,
}
a.desktopShell.trayState = "probing"
a.webView2Recovery = newWebView2RecoveryCoordinator(a)
@@ -475,6 +500,9 @@ func (a *App) startup(ctx context.Context) {
slog.Debug("desktop: repair native icon integration", "err", err)
}
})
a.goSafe("applyWindowIconsFromExecutable", func() {
applyWindowIconsFromExecutable()
})
if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
@@ -706,7 +734,9 @@ func (a *App) restoreOrBuildTabs() {
if cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {
f = singleSurfaceTabsFile(f)
}
// Restore remote tabs as disconnected shells; activation performs the
// first network work so desktop startup remains offline-safe.
a.restoreRemoteTabShells(f)
if len(f.Tabs) > 0 {
toBuild := make([]*WorkspaceTab, 0, len(f.Tabs))
for _, entry := range f.Tabs {
@@ -767,6 +797,11 @@ func (a *App) restoreOrBuildTabs() {
}
return
}
if len(f.RemoteTabs) > 0 {
// A remote-only single-surface layout is restored above as disconnected
// shells. It is not a first launch and must not grow a fallback Global tab.
return
}
// First launch: create a default Global tab.
tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "")
@@ -1754,6 +1789,9 @@ func (a *App) SetCollaborationMode(mode string) {
// turn cannot observe collaboration, approval, and goal from different UI
// generations.
func (a *App) SetComposerProfileForTab(tabID, collaborationMode, toolApprovalMode, goal string) ([]string, error) {
if a.isRemoteTab(tabID) {
return []string{}, nil
}
collaborationMode = normalizeCollaborationMode(collaborationMode)
toolApprovalMode = normalizeToolApprovalMode(toolApprovalMode)
goal = strings.TrimSpace(goal)
@@ -2038,8 +2076,7 @@ func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
// topic index repair fails here, keep the session usable and let persisted
// session metadata repair the topic index later instead of surfacing a false
// "new session failed" error to the frontend.
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
_ = ensureTopicIndexedWithCreatedAt(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto, time.Now().UnixMilli())
}
func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
@@ -2069,8 +2106,7 @@ func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
}
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
_ = ensureTopicIndexedWithCreatedAt(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto, time.Now().UnixMilli())
path := a.currentSessionPathFor(tab)
a.persistTabSessionPath(tab, path)
a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(path))
@@ -6506,6 +6542,12 @@ func (a *App) CancelJobForTab(tabID, jobID string) (bool, error) {
return false, fmt.Errorf("job id is required")
}
if tabID != "" {
if a.isRemoteTab(tabID) {
if err := a.CancelRemoteTabJobs(tabID, []string{jobID}); err != nil {
return false, err
}
return true, nil
}
ctrl := a.ctrlForRuntimeTabID(tabID)
if ctrl != nil {
return cancelJobForController(ctrl, jobID)
@@ -6538,24 +6580,25 @@ func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
// Meta describes the session for the frontend's header and status line.
type Meta struct {
Label string `json:"label"`
Ready bool `json:"ready"`
Runtime SessionRuntimeView `json:"runtime"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
SessionPath string `json:"sessionPath,omitempty"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
Cwd string `json:"cwd"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
WorkspaceName string `json:"workspaceName,omitempty"`
WorkspacePath string `json:"workspacePath,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
ImageInputEnabled bool `json:"imageInputEnabled"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
Label string `json:"label"`
Ready bool `json:"ready"`
Runtime SessionRuntimeView `json:"runtime"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
SessionPath string `json:"sessionPath,omitempty"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
Cwd string `json:"cwd"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
WorkspaceName string `json:"workspaceName,omitempty"`
WorkspacePath string `json:"workspacePath,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
ImageInputEnabled bool `json:"imageInputEnabled"`
VisionFallbackEnabled bool `json:"visionFallbackEnabled,omitempty"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
// TokenMode and AgentPreset are deprecated dual-write wire values pinned to
// their safe defaults; one-version-old frontends still parse them.
TokenMode string `json:"tokenMode"`
@@ -6567,6 +6610,9 @@ type Meta struct {
CanonicalTodos *[]evidence.TodoItem `json:"canonicalTodos,omitempty"`
// Closed completed todo fingerprints from this session and its lineage.
DismissedTodoBatches []string `json:"dismissedTodoBatches,omitempty"`
// Remote marks a remote session tab; its readiness is carried by the
// remote-tab state channel rather than a local controller.
Remote *RemoteTabRef `json:"remote,omitempty"`
}
type GoalRuntimeView struct {
@@ -6610,24 +6656,11 @@ func (a *App) Meta() Meta {
return a.MetaForTab("")
}
// imageInputEnabledForRootModel reports whether the resolved model supports
// image input. EXPENSIVE: it loads the workspace config and resolves the model
// catalog. Never call it on a bound-method request path — MetaForTab serves
// the cached tabMetaExtras instead, and only refreshTabMetaExtras (background)
// calls this.
func (a *App) imageInputEnabledForRootModel(root, ref string) bool {
func (a *App) loadConfigForVision(root string) (*config.Config, error) {
if hook := a.configLoadForRootHook; hook != nil {
hook(root)
}
cfg, err := config.LoadForRoot(root)
if err == nil && ref == "" {
ref = cfg.DefaultModel
}
if err != nil || ref == "" {
return false
}
entry, ok := cfg.ResolveModel(ref)
return ok && config.EffectiveVision(entry)
return config.LoadForRoot(root)
}
func (a *App) MetaForTab(tabID string) Meta {
@@ -6637,7 +6670,11 @@ func (a *App) MetaForTab(tabID string) Meta {
runtimeView := a.sessionRuntimeViewLocked(tab)
a.mu.RUnlock()
if tab == nil {
return Meta{EventChannel: eventChannel}
meta := Meta{EventChannel: eventChannel}
if ref, ok := a.remoteTabRefFor(tabID); ok {
meta.Remote = &ref
}
return meta
}
cwd := snap.workspaceRoot
if cwd == "" {
@@ -6669,31 +6706,32 @@ func (a *App) MetaForTab(tabID string) Meta {
sessionDigest = branchMeta.ContentDigest
}
return Meta{
Label: snap.label,
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
Runtime: runtimeView,
StartupErr: snap.startupErr,
EventChannel: eventChannel,
SessionPath: sessionPath,
SessionRevision: sessionRevision,
SessionDigest: sessionDigest,
Cwd: cwd,
WorkspaceRoot: cwd,
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
WorkspacePath: cwd,
GitBranch: extras.gitBranch,
ImageInputEnabled: extras.imageInputEnabled,
AutoApproveTools: autoApproveTools,
Bypass: autoApproveTools,
CollaborationMode: collaborationMode,
TokenMode: tokenMode,
AgentPreset: agentPreset,
ToolApprovalMode: toolApprovalMode,
Goal: goal,
GoalStatus: goalStatus,
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
CanonicalTodos: ctrlTodos(snap.ctrl),
DismissedTodoBatches: a.dismissedTodoBatchesForSession(sessionPath),
Label: snap.label,
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
Runtime: runtimeView,
StartupErr: snap.startupErr,
EventChannel: eventChannel,
SessionPath: sessionPath,
SessionRevision: sessionRevision,
SessionDigest: sessionDigest,
Cwd: cwd,
WorkspaceRoot: cwd,
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
WorkspacePath: cwd,
GitBranch: extras.gitBranch,
ImageInputEnabled: extras.imageInputEnabled,
VisionFallbackEnabled: extras.visionFallbackEnabled,
AutoApproveTools: autoApproveTools,
Bypass: autoApproveTools,
CollaborationMode: collaborationMode,
TokenMode: tokenMode,
AgentPreset: agentPreset,
ToolApprovalMode: toolApprovalMode,
Goal: goal,
GoalStatus: goalStatus,
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
CanonicalTodos: ctrlTodos(snap.ctrl),
DismissedTodoBatches: a.dismissedTodoBatchesForSession(sessionPath),
}
}
@@ -7066,6 +7104,10 @@ type ServerView struct {
Status string `json:"status"`
StartIntent string `json:"startIntent,omitempty"` // deprecated: derived from Enabled
RuntimeState string `json:"runtimeState,omitempty"`
ProtocolVersion string `json:"protocolVersion,omitempty"`
SessionState string `json:"sessionState,omitempty"`
ReconnectAttempts int `json:"reconnectAttempts,omitempty"`
ErrorKind string `json:"errorKind,omitempty"`
Availability string `json:"availability,omitempty"`
Enabled bool `json:"enabled"`
Installed bool `json:"installed"`
@@ -7720,12 +7762,7 @@ func (a *App) mcpServersView() []ServerView {
}
seen[s.Name] = true
connected[s.Name] = true
view := ServerView{
Name: s.Name, Transport: s.Transport, Status: "connected", RuntimeState: "ready",
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
HasTools: s.HasTools,
ToolList: pluginToolsToView(s.ToolList),
}
view := pluginServerToView(s)
if p, ok := configured[s.Name]; ok {
view = withPluginConfigInWorkspace(view, p, workspaceRoot)
}
@@ -9184,13 +9221,7 @@ func findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool)
}
for _, s := range ctrl.Host().Servers() {
if s.Name == name {
view := ServerView{
Name: s.Name, Transport: s.Transport, Status: "connected",
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
HasTools: s.HasTools,
ToolList: pluginToolsToView(s.ToolList),
}
return view, true
return pluginServerToView(s), true
}
}
for _, f := range ctrl.Host().Failures() {
@@ -9309,45 +9340,6 @@ func (a *App) Models() []ModelInfo {
return a.ModelsForTab("")
}
func (a *App) ModelsForTab(tabID string) []ModelInfo {
a.mu.RLock()
curModel := ""
workspaceRoot := ""
var ctrl control.SessionAPI
if tab := a.tabByIDLocked(tabID); tab != nil {
curModel = tab.model
workspaceRoot = tab.WorkspaceRoot
ctrl = tab.Ctrl
}
a.mu.RUnlock()
// The tab controller's merged catalog carries extension sidecar providers
// (plugin/... refs). Read it off-lock: a cold catalog fetch can block on a
// sidecar RPC and must never park a.mu.
var extensionCatalog []provider.Descriptor
if ctrl != nil {
extensionCatalog = ctrl.ProviderCatalog()
}
cfg, err := config.LoadForRoot(workspaceRoot)
if err != nil {
return []ModelInfo{}
}
if entry, ok := cfg.ResolveModel(curModel); ok {
curModel = entry.Name + "/" + entry.Model
}
out := []ModelInfo{}
for i := range cfg.Providers {
p := &cfg.Providers[i]
if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() {
continue
}
for _, m := range p.ChatModelList() {
ref := p.Name + "/" + m
out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})
}
}
return mergeExtensionModelInfos(out, extensionCatalog, curModel)
}
// mergeExtensionModelInfos adds namespaced plugin models from the controller's
// merged provider catalog. Base descriptors are already represented by out;
// plugin refs need no provider-access gate because enabling the package grants
@@ -9524,24 +9516,23 @@ func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
var (
// sessionLeaseContentionRetryInterval and sessionLeaseContentionRetryAttempts
// bound the retry window for startup session-lease binds that hit a
// transient in-process holder. CleanupStaleRunning probes a running
// sub-agent's parent session lease inside every controller build, holding
// it only for the duration of a metadata rewrite (sub-millisecond); a
// concurrent tab build that races that probe must not surface a spurious
// "already open in another Reasonix window" error for a lease that is
// genuinely free once the probe releases it. A lease held by another
// window or process stays held for its whole lifetime, so the bounded
// retry still fails fast there.
// bound the retry window for lease or removal-guard acquisition that hits a
// transient in-process holder. CleanupStaleRunning and catalog persistence
// can hold the session lease or save lock briefly while a concurrent tab
// bind or archive begins; those callers must not surface a spurious
// "already open in another Reasonix window" error for ownership that is
// genuinely free once the short operation finishes. A lease held by another
// window or process stays held for its whole lifetime, so the bounded retry
// still fails fast there.
sessionLeaseContentionRetryInterval = 50 * time.Millisecond
sessionLeaseContentionRetryAttempts = 2
)
// withSessionLeaseContentionRetry retries acquire while it fails with
// agent.ErrSessionLeaseHeld, absorbing sub-second contention windows created
// by transient in-process lease probes. Any other error is returned
// immediately, and a lease that remains held after the bounded retries is
// reported as-is.
// by transient in-process lease or save-lock holders. Any other error is
// returned immediately, and a lease that remains held after the bounded
// retries is reported as-is.
func withSessionLeaseContentionRetry[T any](acquire func() (T, error)) (T, error) {
var zero T
for attempt := 0; ; attempt++ {
@@ -9674,7 +9665,13 @@ type modelSwitchTiming struct {
}
func (a *App) SetModelForTab(tabID, name string) (retErr error) {
if a.ctx == nil || name == "" {
if name == "" {
return nil
}
if a.isRemoteTab(tabID) {
return a.SetRemoteTabModel(tabID, name)
}
if a.ctx == nil {
return nil
}
tab := a.tabByID(tabID)
@@ -9689,30 +9686,7 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) {
}
timing := modelSwitchTiming{}
totalStarted := time.Now()
defer func() {
timing.Total = time.Since(totalStarted)
if retErr != nil {
timing.Outcome = "failed"
} else {
timing.Outcome = "ok"
}
slog.Debug(
"desktop: model switch timing",
"tab", tab.ID,
"outcome", timing.Outcome,
"total_ms", timing.Total.Milliseconds(),
"lock_wait_ms", timing.LockWait.Milliseconds(),
"prepare_ms", timing.Prepare.Milliseconds(),
"config_ms", timing.Config.Milliseconds(),
"snapshot_ms", timing.Snapshot.Milliseconds(),
"build_ms", timing.Build.Milliseconds(),
"lease_resume_ms", timing.LeaseAndResume.Milliseconds(),
"swap_persist_ms", timing.SwapAndPersist.Milliseconds(),
)
if a.modelSwitchTimingHook != nil {
a.modelSwitchTimingHook(timing)
}
}()
defer a.recordModelSwitchTiming(tab.ID, &timing, totalStarted, &retErr)
// Same build+swap shape as rebuildSetting; hold the same lock so a settings
// rebuild (manual or from the deferred-rebuild retry loop) and a model
// switch cannot interleave on one tab.
+1
View File
@@ -525,6 +525,7 @@ func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
seedLegacyTopicBridge(t, projectRoot)
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
+184 -7
View File
@@ -237,11 +237,11 @@ func isolateDesktopUserDirs(t *testing.T) string {
t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
t.Setenv("AppData", appData)
// Process-local catalog projections pin SQLite files under cache. Close them
// before TempDir cleanup so Windows does not fail unlinkat on open handles.
// Close process-local SQLite handles before TempDir cleanup for Windows.
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
desktopTopicState.close()
_ = history.CloseSharedCatalog(ctx)
_ = stats.CloseUsageCatalogs(ctx)
_ = taskcatalog.ShutdownShared(ctx)
@@ -1513,7 +1513,7 @@ api_key_env = "DEEPSEEK_API_KEY"
if !p.BuiltIn {
t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
}
if !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
if !p.Added || !p.KeySet || len(p.Models) != 3 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Models[2] != "deepseek-v4-flash-vision-exp" || !slices.Equal(p.VisionModels, []string{"deepseek-v4-flash-vision-exp"}) || p.Default != "deepseek-v4-flash" {
t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
}
if got.DefaultModel != "deepseek/deepseek-v4-flash" {
@@ -1704,7 +1704,7 @@ api_key_env = "DEEPSEEK_API_KEY"
if !ok {
t.Fatal("deepseek provider not saved")
}
if len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
if len(p.Models) != 3 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Models[2] != "deepseek-v4-flash-vision-exp" || !slices.Equal(p.VisionModels, []string{"deepseek-v4-flash-vision-exp"}) || p.Default != "deepseek-v4-flash" {
t.Fatalf("deepseek provider after add = %+v, want official model list", p)
}
if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
@@ -1735,6 +1735,9 @@ func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
t.Fatalf("preset %q view has missing fields: %+v", id, preset)
}
if preset.ID == "opencode-go-recommended" && (preset.DisplayGroup != "opencode" || preset.DisplaySection != "go" || preset.DisplayTier != "primary" || preset.RouteKind != "bundle") {
t.Fatalf("recommended OpenCode metadata = %+v", preset)
}
}
}
@@ -2140,6 +2143,180 @@ func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
}
}
func TestAddOpenCodeGoRecommendedPresetCompletesMissingRoutes(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("OPENCODE_GO_API_KEY", "")
os.Unsetenv("OPENCODE_GO_API_KEY")
preset, ok := config.CuratedProviderPreset("opencode-go-recommended")
if !ok || len(preset.Entries) != 3 {
t.Fatalf("recommended preset = %+v, found=%v", preset, ok)
}
cfg := config.Default()
seed := preset.Entries[0]
seed.PresetID = "opencode-go"
if err := cfg.UpsertProvider(seed); err != nil {
t.Fatalf("seed existing OpenCode Go route: %v", err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save seed config: %v", err)
}
partial := providerPresetViewByID(t, NewApp().Settings(), preset.ID)
if partial.Status != providerPresetStatusPartial || len(partial.MissingProviderNames) != 2 {
t.Fatalf("recommended preset partial view = %+v, want two missing routes", partial)
}
if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-opencode"); err != nil {
t.Fatalf("AddProviderPresetAccess: %v", err)
} else if warning != "" {
t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
}
cfg = config.LoadForEdit(config.UserConfigPath())
for _, entry := range preset.Entries {
if _, ok := cfg.Provider(entry.Name); !ok {
t.Fatalf("missing recommended route %q after completion", entry.Name)
}
}
data, err := os.ReadFile(config.UserCredentialsPath())
if err != nil {
t.Fatalf("read saved credentials: %v", err)
}
if !strings.Contains(string(data), "OPENCODE_GO_API_KEY=sk-opencode") {
t.Fatalf("saved credentials missing Go key: %s", data)
}
}
func TestAddOpenCodeGoRecommendedPresetSelectsUsableDefaultForFreshSetup(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("OPENCODE_GO_API_KEY", "")
os.Unsetenv("OPENCODE_GO_API_KEY")
cfg := config.Default()
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save fresh config: %v", err)
}
if _, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-opencode"); err != nil {
t.Fatalf("AddProviderPresetAccess: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if got.DefaultModel != "opencode-go/glm-5.3" {
t.Fatalf("default model = %q, want ready-to-use OpenCode Go default", got.DefaultModel)
}
}
func TestAddOpenCodeGoRecommendedPresetPreservesConfiguredDefault(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("OPENCODE_GO_API_KEY", "")
os.Unsetenv("OPENCODE_GO_API_KEY")
cfg := config.Default()
if err := cfg.UpsertProvider(config.ProviderEntry{
Name: "local-ready",
Kind: "openai",
BaseURL: "http://127.0.0.1:11434/v1",
Models: []string{"local-model"},
Default: "local-model",
}); err != nil {
t.Fatalf("upsert configured provider: %v", err)
}
if err := cfg.SetDefaultModel("local-ready/local-model"); err != nil {
t.Fatalf("set configured default: %v", err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save configured default: %v", err)
}
if _, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-opencode"); err != nil {
t.Fatalf("AddProviderPresetAccess: %v", err)
}
if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "local-ready/local-model" {
t.Fatalf("default model = %q, want existing configured default preserved", got)
}
}
func TestAddOpenCodeGoRecommendedPresetPreservesModifiedRoute(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("OPENCODE_GO_API_KEY", "")
os.Unsetenv("OPENCODE_GO_API_KEY")
preset, ok := config.CuratedProviderPreset("opencode-go-recommended")
if !ok || len(preset.Entries) != 3 {
t.Fatalf("recommended preset = %+v, found=%v", preset, ok)
}
cfg := config.Default()
modified := preset.Entries[0]
modified.BaseURL = "https://custom.example/v1"
modified.PresetID = "opencode-go"
if err := cfg.UpsertProvider(modified); err != nil {
t.Fatalf("seed modified OpenCode Go route: %v", err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save modified config: %v", err)
}
if _, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-opencode"); err != nil {
t.Fatalf("AddProviderPresetAccess: %v", err)
}
cfg = config.LoadForEdit(config.UserConfigPath())
got, ok := cfg.Provider("opencode-go")
if !ok || got.BaseURL != "https://custom.example/v1" {
t.Fatalf("modified route = %+v, want preserved custom endpoint", got)
}
for _, name := range []string{"opencode-go-anthropic", "opencode-go-responses"} {
if _, ok := cfg.Provider(name); !ok {
t.Fatalf("missing route %q after completing bundle around modified route", name)
}
}
}
func TestAddOpenCodeGoRecommendedPresetRejectsConflictAtomically(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("OPENCODE_GO_API_KEY", "")
os.Unsetenv("OPENCODE_GO_API_KEY")
cfg := config.Default()
conflict := config.ProviderEntry{
Name: "opencode-go",
Kind: "openai",
BaseURL: "https://custom.example/v1",
Models: []string{"custom-model"},
Default: "custom-model",
APIKeyEnv: "OPENCODE_GO_API_KEY",
PresetID: "custom",
PresetVersion: 1,
}
if err := cfg.UpsertProvider(conflict); err != nil {
t.Fatalf("upsert conflicting provider: %v", err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save conflict config: %v", err)
}
if warning, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-should-not-save"); err == nil {
t.Fatal("AddProviderPresetAccess unexpectedly accepted same-name conflict")
} else if !strings.Contains(err.Error(), "opencode-go") {
t.Fatalf("AddProviderPresetAccess error = %v, want opencode-go conflict", err)
} else if warning != "" {
t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
}
cfg = config.LoadForEdit(config.UserConfigPath())
if _, ok := cfg.Provider("opencode-go-anthropic"); ok {
t.Fatal("conflicting bundle partially installed Anthropic route")
}
if _, err := os.Stat(config.UserCredentialsPath()); err == nil {
data, readErr := os.ReadFile(config.UserCredentialsPath())
if readErr != nil {
t.Fatalf("read credentials: %v", readErr)
}
if strings.Contains(string(data), "sk-should-not-save") {
t.Fatalf("conflicting bundle saved credentials: %s", data)
}
}
}
func TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("DEEPSEEK_API_KEY", "")
@@ -3065,8 +3242,8 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden)
}
}
if len(models) != 4 {
t.Fatalf("Models() len = %d, want 4: %+v", len(models), models)
if len(models) != 5 {
t.Fatalf("Models() len = %d, want 5: %+v", len(models), models)
}
}
@@ -4421,7 +4598,7 @@ func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) {
app.activeTabID = tab.ID
installSessionCatalogForTest(t, app, sessionDirA, "project", projectA)
t.Cleanup(oldCtrl.Close)
sessions := app.ListSessions()
sessions := listSessionsAfterPinnedOwnerReconcile(t, app, sessionDirA, projectA)
if len(sessions) == 0 {
t.Fatal("ListSessions() returned no sessions")
}
+93
View File
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
@@ -75,9 +76,52 @@ func (v ActiveWorkView) active() bool {
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
// preflight aid only; rebuild paths still re-check active work atomically.
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
if a.isRemoteTab(tabID) {
view, err := a.remoteActiveWorkForTab(tabID)
if err != nil {
// A remote tab whose status cannot be observed must fail closed: the
// caller must not silently detach potentially mutating work.
return ActiveWorkView{Running: true, Cancellable: true, Jobs: []JobView{}}
}
return view
}
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
}
func (a *App) remoteActiveWorkForTab(tabID string) (ActiveWorkView, error) {
raw, err := a.RemoteTabStatus(tabID)
view := ActiveWorkView{Jobs: []JobView{}}
if err != nil {
return view, err
}
var status struct {
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
BackgroundJobs int `json:"backgroundJobs"`
Cancellable bool `json:"cancellable"`
Jobs []struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Status string `json:"status"`
StartedAt int64 `json:"startedAt"`
} `json:"jobs"`
}
if err := json.Unmarshal(raw, &status); err != nil {
return view, err
}
view.Running = status.Running || status.BackgroundJobs > 0 || len(status.Jobs) > 0
view.PendingPrompt = status.PendingPrompt
view.Cancellable = status.Cancellable || status.Running || status.PendingPrompt || status.BackgroundJobs > 0 || len(status.Jobs) > 0
for _, job := range status.Jobs {
view.Jobs = append(view.Jobs, JobView{
ID: job.ID, Kind: job.Kind, Label: job.Label,
Status: job.Status, StartedAt: job.StartedAt,
})
}
return view, nil
}
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
// keeps cancelled-but-unwinding jobs visible until their done channels close.
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
@@ -311,6 +355,55 @@ const stopAndCloseGrace = 15 * time.Second
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
// choice. stop_and_close never removes the tab until all owned work is idle.
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
a.remoteTabMu.Lock()
_, isRemote := a.remoteTabs[tabID]
a.remoteTabMu.Unlock()
if isRemote {
switch strings.TrimSpace(policy) {
case "keep_running":
return a.CloseRemoteTab(tabID)
case "stop_and_close":
work, err := a.remoteActiveWorkForTab(tabID)
if err != nil {
return fmt.Errorf("remote work status is unavailable; the task was kept open: %w", err)
}
if !work.active() {
return a.CloseRemoteTab(tabID)
}
if err := a.CancelRemoteTab(tabID); err != nil {
return err
}
ids := make([]string, 0, len(work.Jobs))
for _, job := range work.Jobs {
ids = append(ids, job.ID)
}
if len(ids) > 0 {
if err := a.CancelRemoteTabJobs(tabID, ids); err != nil {
return err
}
}
deadline := time.NewTimer(stopAndCloseGrace)
defer deadline.Stop()
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
work, err = a.remoteActiveWorkForTab(tabID)
if err != nil {
return fmt.Errorf("remote work status is unavailable; the task was kept open: %w", err)
}
if !work.active() {
return a.CloseRemoteTab(tabID)
}
select {
case <-deadline.C:
return fmt.Errorf("remote work did not stop within %s; the task was kept open", stopAndCloseGrace)
case <-ticker.C:
}
}
default:
return fmt.Errorf("unknown close policy %q", policy)
}
}
switch strings.TrimSpace(policy) {
case "keep_running":
return a.closeTab(tabID, true)
@@ -0,0 +1,236 @@
//go:build windows && reasonix_transcript_smoke
package main
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"time"
"github.com/wailsapp/go-webview2/pkg/edge"
"reasonix/desktop/internal/transcriptsmoke"
)
func (host *selectionSmokeHost) captureStage(
iteration int,
stage captureStage,
baseline smokeGeometry,
baselineImage image.Image,
) (smokeFrameResult, error) {
label := fmt.Sprintf("iteration-%d-%s", iteration, stage.label)
snapshot, err := requestSnapshot(host.chromium, host.messages, host.webViewErrors, label, stage.frames, stage.delay)
if err != nil {
return smokeFrameResult{}, err
}
currentImage, capturePath, err := host.captureAndSave(label)
if err != nil {
return smokeFrameResult{}, err
}
pixelResult, diff, err := transcriptsmoke.Compare(
baselineImage, currentImage, snapshot.Geometry.Shell, selectionMasks(snapshot.Geometry),
transcriptsmoke.CompareOptions{
ChannelThreshold: 12, MaxChangedRatio: 0.0005, MaxConnectedComponent: 128,
ViewportWidth: snapshot.Geometry.Viewport.Width, ViewportHeight: snapshot.Geometry.Viewport.Height,
},
)
if err != nil {
return smokeFrameResult{}, err
}
frame := smokeFrameResult{
Label: label, CapturePath: capturePath, Geometry: snapshot.Geometry,
EventSamples: snapshot.EventSamples, Pixel: pixelResult,
GeometryOK: geometryStable(baseline, snapshot.Geometry) && eventSamplesStable(baseline, snapshot.EventSamples),
}
if pixelResult.Passed && frame.GeometryOK {
return frame, nil
}
frame.DiffPath = filepath.Join(host.artifacts, label+"-diff.png")
if err := writePNG(frame.DiffPath, diff); err != nil {
return smokeFrameResult{}, err
}
return frame, nil
}
func (host *selectionSmokeHost) captureAndSave(label string) (image.Image, string, error) {
data, err := capturePreview(host.chromium)
if err != nil {
return nil, "", fmt.Errorf("capture %s: %w", label, err)
}
path := filepath.Join(host.artifacts, label+".png")
if err := os.WriteFile(path, data, 0o600); err != nil {
return nil, "", err
}
value, err := decodePNG(data)
return value, path, err
}
func (host *selectionSmokeHost) warmCompositor() error {
for attempt := 1; attempt <= 3; attempt++ {
if _, err := capturePreview(host.chromium); err != nil {
return fmt.Errorf("warm WebView2 compositor (attempt %d): %w", attempt, err)
}
pumpFor(100 * time.Millisecond)
}
return nil
}
func selectionMasks(geometry smokeGeometry) []transcriptsmoke.Rect {
masks := make([]transcriptsmoke.Rect, 0, len(geometry.SelectionRects)+1)
for _, selectionRect := range geometry.SelectionRects {
masks = append(masks, selectionRect.Expand(6))
}
if geometry.Toolbar != nil {
masks = append(masks, geometry.Toolbar.Expand(24))
}
return masks
}
func requestSnapshot(
chromium *edge.Chromium,
messages <-chan string,
webViewErrors <-chan error,
label string,
frames int,
delay int,
) (smokeMessage, error) {
encodedLabel, _ := json.Marshal(label)
chromium.Eval(fmt.Sprintf("window.__reasonixSelectionSmoke.snapshot(%s,%d,%d)", encodedLabel, frames, delay))
message, err := waitForMessage(messages, webViewErrors, "snapshot", interactionTimeout)
if err != nil {
return smokeMessage{}, err
}
if message.Geometry.Label != label {
return smokeMessage{}, fmt.Errorf("snapshot label mismatch: got %q, want %q", message.Geometry.Label, label)
}
return message, nil
}
func waitForMessage(messages <-chan string, webViewErrors <-chan error, messageType string, timeout time.Duration) (smokeMessage, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
pumpWindowsMessages()
if err := pollWebViewError(webViewErrors); err != nil {
return smokeMessage{}, err
}
message, found, err := pollSmokeMessage(messages)
if err != nil {
return smokeMessage{}, err
}
if found && message.Type == "error" {
return smokeMessage{}, fmt.Errorf("fixture: %s", message.Message)
}
if found && message.Type == messageType {
return message, nil
}
time.Sleep(time.Millisecond)
}
return smokeMessage{}, fmt.Errorf("timed out waiting for fixture message %q", messageType)
}
func pollSmokeMessage(messages <-chan string) (smokeMessage, bool, error) {
select {
case raw := <-messages:
var message smokeMessage
if err := json.Unmarshal([]byte(raw), &message); err != nil {
return smokeMessage{}, false, fmt.Errorf("decode fixture message %q: %w", raw, err)
}
return message, true, nil
default:
return smokeMessage{}, false, nil
}
}
func pollWebViewError(errors <-chan error) error {
select {
case err := <-errors:
return fmt.Errorf("WebView2: %w", err)
default:
return nil
}
}
func capturePreview(chromium *edge.Chromium) ([]byte, error) {
capture, err := chromium.StartSmokePreviewCapture()
if err != nil {
return nil, err
}
deadline := time.Now().Add(previewCaptureTimeout)
for time.Now().Before(deadline) {
pumpWindowsMessages()
data, done, pollErr := capture.Poll()
if done {
return data, pollErr
}
time.Sleep(time.Millisecond)
}
return nil, fmt.Errorf("CapturePreview timed out after %s", previewCaptureTimeout)
}
func decodePNG(data []byte) (image.Image, error) {
value, err := png.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode WebView2 preview PNG: %w", err)
}
return value, nil
}
func writePNG(path string, value image.Image) error {
file, err := os.Create(path)
if err != nil {
return err
}
encodeErr := png.Encode(file, value)
closeErr := file.Close()
if encodeErr != nil {
return encodeErr
}
return closeErr
}
func geometryStable(baseline, current smokeGeometry) bool {
return baseline.ScrollTop == current.ScrollTop &&
baseline.ScrollHeight == current.ScrollHeight &&
baseline.ClientHeight == current.ClientHeight &&
current.HostCount == 1 && current.HostStable &&
rectDelta(baseline.Table, current.Table) <= 0.5 &&
rectDelta(baseline.Row, current.Row) <= 0.5 &&
rectDelta(baseline.Target, current.Target) <= 0.5
}
func eventSamplesStable(baseline smokeGeometry, samples []smokeGeometry) bool {
if len(samples) == 0 {
return false
}
for _, sample := range samples {
if !geometryStable(baseline, sample) {
return false
}
}
return true
}
func rectDelta(left, right transcriptsmoke.Rect) float64 {
maximum := 0.0
for _, value := range []float64{
abs(left.Left - right.Left), abs(left.Top - right.Top),
abs(left.Right - right.Right), abs(left.Bottom - right.Bottom),
} {
if value > maximum {
maximum = value
}
}
return maximum
}
func abs(value float64) float64 {
if value < 0 {
return -value
}
return value
}
@@ -0,0 +1,231 @@
//go:build windows && reasonix_transcript_smoke
package main
import (
"fmt"
"image"
"os"
"path/filepath"
"time"
"github.com/wailsapp/go-webview2/pkg/edge"
"golang.org/x/sys/windows"
)
const (
startupTimeout = 60 * time.Second
interactionTimeout = 15 * time.Second
previewCaptureTimeout = 10 * time.Second
)
type selectionSmokeHost struct {
chromium *edge.Chromium
messages chan string
webViewErrors chan error
artifacts string
}
type captureStage struct {
label string
frames int
delay int
}
var selectionCaptureStages = []captureStage{
{"pointer-up", 0, 0},
{"raf-1", 1, 0},
{"raf-2", 1, 0},
{"after-100ms", 0, 100},
{"after-300ms", 0, 300},
}
func runSelectionSmoke(url, script, artifacts string, iterations, width, height int, commit string) (smokeResult, error) {
result := smokeResult{Commit: commit, ClickIntervals: []int{400, 320, 180}}
if iterations <= 0 || width <= 0 || height <= 0 {
return result, fmt.Errorf("iterations and viewport dimensions must be positive")
}
if err := os.MkdirAll(artifacts, 0o755); err != nil {
return result, fmt.Errorf("create artifact directory: %w", err)
}
hwnd, err := createSmokeWindow(width, height)
if err != nil {
return result, err
}
defer destroyWindow.Call(uintptr(hwnd))
dataPath, err := os.MkdirTemp("", "reasonix-selection-webview2-")
if err != nil {
return result, fmt.Errorf("create WebView2 data directory: %w", err)
}
defer os.RemoveAll(dataPath)
host, ready, err := startSelectionSmokeHost(hwnd, url, script, dataPath, artifacts, width, height)
if err != nil {
return result, err
}
defer host.chromium.ShuttingDown()
if ready.Platform != "windows" {
return result, fmt.Errorf("selection fixture reported platform %q, expected windows", ready.Platform)
}
result.WebView2Runtime = host.chromium.SmokeRuntimeVersion()
version := windows.RtlGetVersion()
result.WindowsVersion = fmt.Sprintf("%d.%d.%d", version.MajorVersion, version.MinorVersion, version.BuildNumber)
result.Viewport = ready.Geometry.Viewport
result.DPR = ready.Geometry.DPR
movePointerToClientPoint(hwnd, ready.Point)
if err := host.warmCompositor(); err != nil {
return result, err
}
result.Passed = true
for iteration := 1; iteration <= iterations; iteration++ {
iterationResult, iterationErr := host.runIteration(iteration)
if iterationErr != nil {
return result, iterationErr
}
result.Iterations = append(result.Iterations, iterationResult)
result.Passed = result.Passed && iterationResult.Passed
}
return result, nil
}
func startSelectionSmokeHost(
hwnd windows.Handle,
url, script, dataPath, artifacts string, width, height int,
) (*selectionSmokeHost, smokeMessage, error) {
host := &selectionSmokeHost{
chromium: edge.NewChromium(), messages: make(chan string, 256),
webViewErrors: make(chan error, 1), artifacts: artifacts,
}
navigated := make(chan struct{}, 1)
host.chromium.DataPath = filepath.Clean(dataPath)
host.chromium.SetErrorCallback(func(err error) { offerError(host.webViewErrors, err) })
host.chromium.MessageCallback = func(message string, _ *edge.ICoreWebView2, _ *edge.ICoreWebView2WebMessageReceivedEventArgs) {
select {
case host.messages <- message:
default:
}
}
host.chromium.NavigationCompletedCallback = func(_ *edge.ICoreWebView2, _ *edge.ICoreWebView2NavigationCompletedEventArgs) {
select {
case navigated <- struct{}{}:
default:
}
}
if !host.chromium.Embed(uintptr(hwnd)) {
return nil, smokeMessage{}, fmt.Errorf("embed WebView2 controller")
}
host.chromium.ResizeWithBounds(&edge.Rect{Left: 0, Top: 0, Right: int32(width), Bottom: int32(height)})
_ = host.chromium.Show()
host.chromium.Focus()
host.chromium.Navigate(url)
ready, err := waitForSelectionFixture(host, navigated, script)
if err != nil {
host.chromium.ShuttingDown()
return nil, smokeMessage{}, err
}
return host, ready, nil
}
func waitForSelectionFixture(host *selectionSmokeHost, navigated <-chan struct{}, script string) (smokeMessage, error) {
deadline := time.Now().Add(startupTimeout)
injected := false
for time.Now().Before(deadline) {
pumpWindowsMessages()
if err := pollWebViewError(host.webViewErrors); err != nil {
return smokeMessage{}, err
}
if !injected {
select {
case <-navigated:
injected = true
host.chromium.Eval(script)
default:
}
}
message, found, err := pollSmokeMessage(host.messages)
if err != nil {
return smokeMessage{}, err
}
if found && message.Type == "error" {
return smokeMessage{}, fmt.Errorf("fixture: %s", message.Message)
}
if found && message.Type == "ready" {
return message, nil
}
time.Sleep(time.Millisecond)
}
return smokeMessage{}, fmt.Errorf("selection fixture did not become ready within %s", startupTimeout)
}
func (host *selectionSmokeHost) runIteration(iteration int) (smokeIterationResult, error) {
result := smokeIterationResult{Iteration: iteration, Passed: true}
if iteration > 1 {
host.chromium.Eval(fmt.Sprintf("window.__reasonixSelectionSmoke.reset(%d)", iteration))
if _, err := waitForMessage(host.messages, host.webViewErrors, "reset", interactionTimeout); err != nil {
return result, err
}
pumpFor(600 * time.Millisecond)
}
baseline, err := requestSnapshot(
host.chromium, host.messages, host.webViewErrors, fmt.Sprintf("iteration-%d-baseline", iteration), 0, 0,
)
if err != nil {
return result, err
}
baselineImage, baselinePath, err := host.captureAndSave(fmt.Sprintf("iteration-%d-baseline", iteration))
if err != nil {
return result, err
}
result.Baseline = baselinePath
if err := sendNativeClicksBeforeFinal(); err != nil {
return result, err
}
if err := sendLeftButton(mouseEventFLeftDown); err != nil {
return result, err
}
pumpFor(30 * time.Millisecond)
downFrame, err := host.captureStage(
iteration, captureStage{"pointer-down", 0, 0}, baseline.Geometry, baselineImage,
)
if err != nil {
return result, err
}
result.Frames = append(result.Frames, downFrame)
result.Passed = result.Passed && downFrame.Pixel.Passed && downFrame.GeometryOK
if err := sendLeftButton(mouseEventFLeftUp); err != nil {
return result, err
}
return host.captureIterationStages(iteration, baseline.Geometry, baselineImage, result, time.Now())
}
func (host *selectionSmokeHost) captureIterationStages(
iteration int,
baseline smokeGeometry,
baselineImage image.Image,
result smokeIterationResult,
pointerUpAt time.Time,
) (smokeIterationResult, error) {
for _, stage := range selectionCaptureStages {
if stage.delay > 0 {
if remaining := time.Until(pointerUpAt.Add(time.Duration(stage.delay) * time.Millisecond)); remaining > 0 {
pumpFor(remaining)
}
}
capture := stage
capture.delay = 0
frame, err := host.captureStage(iteration, capture, baseline, baselineImage)
if err != nil {
return result, err
}
result.Frames = append(result.Frames, frame)
result.Passed = result.Passed && frame.Pixel.Passed && frame.GeometryOK
}
return result, nil
}
func offerError(target chan<- error, err error) {
select {
case target <- err:
default:
}
}
@@ -0,0 +1,123 @@
//go:build windows && reasonix_transcript_smoke
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"runtime"
"reasonix/desktop/internal/transcriptsmoke"
)
type smokeGeometry struct {
Label string `json:"label"`
DPR float64 `json:"dpr"`
Viewport smokeViewport `json:"viewport"`
Shell transcriptsmoke.Rect `json:"shell"`
Table transcriptsmoke.Rect `json:"table"`
Row transcriptsmoke.Rect `json:"row"`
Target transcriptsmoke.Rect `json:"target"`
Toolbar *transcriptsmoke.Rect `json:"toolbar"`
SelectionRects []transcriptsmoke.Rect `json:"selectionRects"`
ScrollTop float64 `json:"scrollTop"`
ScrollHeight float64 `json:"scrollHeight"`
ClientHeight float64 `json:"clientHeight"`
HostCount int `json:"hostCount"`
HostStable bool `json:"hostStable"`
HostState string `json:"hostState"`
}
type smokeViewport struct {
Width float64 `json:"width"`
Height float64 `json:"height"`
}
type smokePoint struct {
X int `json:"x"`
Y int `json:"y"`
}
type smokeMessage struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
Point smokePoint `json:"point,omitempty"`
Geometry smokeGeometry `json:"geometry,omitempty"`
EventSamples []smokeGeometry `json:"eventSamples,omitempty"`
Platform string `json:"platform,omitempty"`
Iteration int `json:"iteration,omitempty"`
}
type smokeFrameResult struct {
Label string `json:"label"`
CapturePath string `json:"capturePath"`
DiffPath string `json:"diffPath,omitempty"`
Geometry smokeGeometry `json:"geometry"`
EventSamples []smokeGeometry `json:"eventSamples"`
Pixel transcriptsmoke.CompareResult `json:"pixel"`
GeometryOK bool `json:"geometryOK"`
}
type smokeIterationResult struct {
Iteration int `json:"iteration"`
Baseline string `json:"baseline"`
Frames []smokeFrameResult `json:"frames"`
Passed bool `json:"passed"`
}
type smokeResult struct {
Passed bool `json:"passed"`
Commit string `json:"commit"`
WindowsVersion string `json:"windowsVersion"`
WebView2Runtime string `json:"webView2Runtime"`
Viewport smokeViewport `json:"viewport"`
DPR float64 `json:"dpr"`
ClickIntervals []int `json:"clickIntervalsMs"`
Iterations []smokeIterationResult `json:"iterations"`
Error string `json:"error,omitempty"`
}
func init() {
runtime.LockOSThread()
}
func main() {
url := flag.String("url", "http://127.0.0.1:4173/?mock=bench&bench=1", "built Reasonix bench URL")
scriptPath := flag.String("script", "transcript_selection_smoke_contract.js", "selection assertion contract")
artifacts := flag.String("artifacts", "transcript-selection-smoke-artifacts", "PNG and JSON artifact directory")
resultPath := flag.String("result-file", "", "optional result JSON path")
iterations := flag.Int("iterations", 3, "native multi-click iterations")
width := flag.Int("width", 1200, "WebView2 viewport width")
height := flag.Int("height", 800, "WebView2 viewport height")
commit := flag.String("commit", os.Getenv("GITHUB_SHA"), "tested source commit")
flag.Parse()
result := smokeResult{Commit: *commit, ClickIntervals: []int{400, 320, 180}}
script, err := os.ReadFile(*scriptPath)
if err == nil {
result, err = runSelectionSmoke(*url, string(script), *artifacts, *iterations, *width, *height, *commit)
}
if err != nil {
result.Passed = false
result.Error = err.Error()
}
payload, marshalErr := json.MarshalIndent(result, "", " ")
if marshalErr != nil {
fmt.Fprintf(os.Stderr, "native selection smoke: encode result: %v\n", marshalErr)
os.Exit(1)
}
if *resultPath != "" {
if writeErr := os.WriteFile(*resultPath, payload, 0o600); writeErr != nil {
fmt.Fprintf(os.Stderr, "native selection smoke: write result: %v\n", writeErr)
os.Exit(1)
}
fmt.Printf("native selection smoke: passed=%t iterations=%d result=%s\n", result.Passed, len(result.Iterations), *resultPath)
} else {
fmt.Println(string(payload))
}
if err != nil || !result.Passed {
os.Exit(1)
}
}
@@ -0,0 +1,162 @@
//go:build windows && reasonix_transcript_smoke
package main
import (
"fmt"
"os"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
registerClassExW = user32.NewProc("RegisterClassExW")
createWindowExW = user32.NewProc("CreateWindowExW")
destroyWindow = user32.NewProc("DestroyWindow")
showWindow = user32.NewProc("ShowWindow")
updateWindow = user32.NewProc("UpdateWindow")
setForegroundWindow = user32.NewProc("SetForegroundWindow")
setFocus = user32.NewProc("SetFocus")
setCursorPos = user32.NewProc("SetCursorPos")
clientToScreen = user32.NewProc("ClientToScreen")
peekMessageW = user32.NewProc("PeekMessageW")
translateMessage = user32.NewProc("TranslateMessage")
dispatchMessageW = user32.NewProc("DispatchMessageW")
defWindowProcW = user32.NewProc("DefWindowProcW")
sendInput = user32.NewProc("SendInput")
)
const (
wsOverlappedWindow = 0x00CF0000
swShow = 5
pmRemove = 0x0001
inputMouse = 0
mouseEventFLeftDown = 0x0002
mouseEventFLeftUp = 0x0004
)
type smokeWindowClass struct {
cbSize uint32
style uint32
wndProc uintptr
clsExtra int32
wndExtra int32
instance windows.Handle
icon windows.Handle
cursor windows.Handle
background windows.Handle
menuName *uint16
className *uint16
iconSmall windows.Handle
}
type nativePoint struct{ x, y int32 }
type smokeMessageLoopEntry struct {
hwnd windows.Handle
message uint32
wParam uintptr
lParam uintptr
time uint32
point nativePoint
private uint32
}
type smokeMouseInput struct {
dx, dy int32
mouseData uint32
flags uint32
time uint32
extraInfo uintptr
}
type smokeInput struct {
typeID uint32
_ uint32
mouse smokeMouseInput
}
func createSmokeWindow(width, height int) (windows.Handle, error) {
var instance windows.Handle
if err := windows.GetModuleHandleEx(0, nil, &instance); err != nil {
return 0, fmt.Errorf("get module handle: %w", err)
}
className, _ := windows.UTF16PtrFromString(fmt.Sprintf("ReasonixSelectionSmoke-%d", os.Getpid()))
windowName, _ := windows.UTF16PtrFromString("Reasonix transcript selection smoke")
windowClass := smokeWindowClass{
cbSize: uint32(unsafe.Sizeof(smokeWindowClass{})),
wndProc: windows.NewCallback(func(hwnd windows.Handle, message uint32, wParam, lParam uintptr) uintptr {
result, _, _ := defWindowProcW.Call(uintptr(hwnd), uintptr(message), wParam, lParam)
return result
}),
instance: instance, className: className,
}
if registered, _, err := registerClassExW.Call(uintptr(unsafe.Pointer(&windowClass))); registered == 0 {
return 0, fmt.Errorf("register window class: %w", err)
}
hwnd, _, err := createWindowExW.Call(
0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(windowName)),
wsOverlappedWindow, 0, 0, uintptr(width), uintptr(height), 0, 0, uintptr(instance), 0,
)
if hwnd == 0 {
return 0, fmt.Errorf("create WebView2 host window: %w", err)
}
showWindow.Call(hwnd, swShow)
updateWindow.Call(hwnd)
setForegroundWindow.Call(hwnd)
setFocus.Call(hwnd)
return windows.Handle(hwnd), nil
}
func movePointerToClientPoint(hwnd windows.Handle, point smokePoint) {
screenPoint := nativePoint{x: int32(point.X), y: int32(point.Y)}
clientToScreen.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&screenPoint)))
setCursorPos.Call(uintptr(screenPoint.x), uintptr(screenPoint.y))
}
func sendNativeClicksBeforeFinal() error {
for _, delay := range []time.Duration{400 * time.Millisecond, 320 * time.Millisecond, 180 * time.Millisecond} {
if err := sendLeftButton(mouseEventFLeftDown); err != nil {
return err
}
pumpFor(30 * time.Millisecond)
if err := sendLeftButton(mouseEventFLeftUp); err != nil {
return err
}
pumpFor(delay)
}
return nil
}
func sendLeftButton(flags uint32) error {
input := smokeInput{typeID: inputMouse}
input.mouse.flags = flags
inserted, _, err := sendInput.Call(1, uintptr(unsafe.Pointer(&input)), unsafe.Sizeof(input))
if inserted != 1 {
return fmt.Errorf("send WebView2 controller mouse input: %w", err)
}
return nil
}
func pumpFor(duration time.Duration) {
deadline := time.Now().Add(duration)
for time.Now().Before(deadline) {
pumpWindowsMessages()
time.Sleep(time.Millisecond)
}
}
func pumpWindowsMessages() {
var message smokeMessageLoopEntry
for {
hasMessage, _, _ := peekMessageW.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0, pmRemove)
if hasMessage == 0 {
return
}
translateMessage.Call(uintptr(unsafe.Pointer(&message)))
dispatchMessageW.Call(uintptr(unsafe.Pointer(&message)))
}
}
+39
View File
@@ -3,6 +3,9 @@ package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
@@ -62,6 +65,8 @@ type crashBreadcrumb struct {
}
type crashReport struct {
EventID string `json:"eventId,omitempty"`
DedupKey string `json:"dedupKey,omitempty"`
InstallID string `json:"installId,omitempty"`
Kind string `json:"kind"`
Version string `json:"version"`
@@ -161,6 +166,37 @@ func baseCrashReport(kind string) crashReport {
}
}
func ensureCrashIdentity(report *crashReport) error {
if report.EventID == "" {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return fmt.Errorf("generate crash event id: %w", err)
}
report.EventID = hex.EncodeToString(value)
}
if report.DedupKey == "" {
basis := strings.Join([]string{
report.Kind,
report.Version,
report.Source,
report.Label,
report.ErrorType,
normalizeCrashFingerprintField(report.ErrorMessage),
normalizeCrashFingerprintField(report.TopFrame),
normalizeCrashFingerprintField(report.FingerprintHint),
}, "\n")
sum := sha256.Sum256([]byte(basis))
report.DedupKey = hex.EncodeToString(sum[:])
}
return nil
}
var crashFingerprintNumber = regexp.MustCompile(`\b\d+\b`)
func normalizeCrashFingerprintField(value string) string {
return crashFingerprintNumber.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "<n>")
}
func topFrameFromStack(stack string) string {
for line := range strings.SplitSeq(stack, "\n") {
line = strings.TrimSpace(line)
@@ -261,6 +297,9 @@ func (a *App) ReportCrash(kind, detail string) error {
if err != nil {
return err
}
if err := ensureCrashIdentity(&r); err != nil {
return err
}
return postCrashReport(a.reqCtx(), c, crashEndpoint, r)
}
+127 -3
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -13,6 +14,8 @@ import (
"time"
"reasonix/internal/config"
"reasonix/internal/filelock"
"reasonix/internal/fileutil"
)
// crash_pending.go captures Go-side panics to disk and ships them on the next
@@ -25,6 +28,10 @@ const (
pendingCrashFile = "crash-pending.json" // legacy single-report path
pendingCrashQueueDir = "crash-pending"
maxPendingCrashes = 10
currentCrashSchema = 3
crashLedgerFile = "crash-upload-ledger-v1.json"
maxCrashLedger = 512
crashLedgerRetention = 180 * 24 * time.Hour
)
var (
@@ -71,6 +78,9 @@ func writePendingCrash(site string, r any, stack []byte) {
func writePendingReport(report crashReport, overwrite bool) bool {
_ = overwrite // retained for source compatibility; the queue never overwrites.
if ensureCrashIdentity(&report) != nil {
return false
}
body, err := json.Marshal(report)
if err != nil {
return false
@@ -95,12 +105,82 @@ func writePendingReport(report crashReport, overwrite bool) bool {
_ = os.Remove(path)
return false
}
return prunePendingCrashQueue(path)
}
func prunePendingCrashQueue(writtenPath string) bool {
paths := pendingCrashQueuePaths()
for len(paths) > maxPendingCrashes {
_ = os.Remove(paths[0])
paths = paths[1:]
victim := -1
for index, candidate := range paths {
body, err := os.ReadFile(candidate)
var header struct {
SchemaVersion int `json:"schemaVersion"`
}
if err != nil || json.Unmarshal(body, &header) != nil || header.SchemaVersion <= currentCrashSchema {
victim = index
break
}
}
if victim < 0 {
break
}
_ = os.Remove(paths[victim])
paths = append(paths[:victim], paths[victim+1:]...)
}
return true
_, err := os.Stat(writtenPath)
return err == nil
}
type crashUploadLedger struct {
Version int `json:"version"`
Entries map[string]string `json:"entries"`
}
func crashLedgerPath() string {
return filepath.Join(config.MemoryUserDir(), "diagnostics", crashLedgerFile)
}
func crashLedgerKey(report crashReport) string {
return report.Version + ":" + report.DedupKey
}
func loadCrashLedger(path string, now time.Time) crashUploadLedger {
ledger := crashUploadLedger{Version: 1, Entries: map[string]string{}}
body, err := os.ReadFile(path)
if err != nil || json.Unmarshal(body, &ledger) != nil || ledger.Version != 1 || ledger.Entries == nil {
return crashUploadLedger{Version: 1, Entries: map[string]string{}}
}
cutoff := now.Add(-crashLedgerRetention)
for key, value := range ledger.Entries {
at, err := time.Parse(time.RFC3339Nano, value)
if err != nil || at.Before(cutoff) {
delete(ledger.Entries, key)
}
}
return ledger
}
func saveCrashLedger(path string, ledger crashUploadLedger) error {
if len(ledger.Entries) > maxCrashLedger {
type row struct{ key, at string }
rows := make([]row, 0, len(ledger.Entries))
for key, at := range ledger.Entries {
rows = append(rows, row{key: key, at: at})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].at > rows[j].at })
for _, item := range rows[maxCrashLedger:] {
delete(ledger.Entries, item.key)
}
}
body, err := json.Marshal(ledger)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return fileutil.AtomicWriteFile(path, body, 0o600)
}
func pendingCrashQueuePaths() []string {
@@ -130,6 +210,8 @@ func removeAllPendingCrashes() {
_ = os.Remove(pendingCrashPath())
_ = os.RemoveAll(pendingCrashDir())
_ = os.Remove(fatalCrashCoveredPath())
_ = os.Remove(crashLedgerPath())
_ = os.Remove(crashLedgerPath() + ".lock")
}
func (a *App) goSafe(site string, fn func()) {
@@ -139,6 +221,14 @@ func (a *App) goSafe(site string, fn func()) {
}()
}
func (a *App) goRemoteTabSafe(site string, fn func()) {
a.remoteTabTasks.Add(1)
a.goSafe(site, func() {
defer a.remoteTabTasks.Done()
fn()
})
}
// flushPendingCrash drains a Go panic captured on a prior run and POSTs it, then
// clears it. Runs at launch alongside the ping; honours the telemetry opt-out by
// dropping the file unsent.
@@ -162,6 +252,18 @@ func (a *App) flushPendingCrash() {
if err != nil {
return
}
lockContext, cancel := context.WithTimeout(a.bootContext(), 3*time.Second)
defer cancel()
ledgerPath := crashLedgerPath()
if os.MkdirAll(filepath.Dir(ledgerPath), 0o700) != nil {
return
}
release, err := filelock.AcquireWithExternalTimeout(lockContext, ledgerPath+".lock", 2*time.Second)
if err != nil {
return
}
defer release()
ledger := loadCrashLedger(ledgerPath, time.Now().UTC())
for _, path := range paths {
body, readErr := readFileUTF8(path)
if readErr != nil {
@@ -172,9 +274,31 @@ func (a *App) flushPendingCrash() {
_ = os.Remove(path)
continue
}
if r.SchemaVersion > currentCrashSchema {
continue
}
identityChanged := r.EventID == "" || r.DedupKey == ""
if ensureCrashIdentity(&r) != nil {
break
}
if identityChanged {
updated, marshalErr := json.Marshal(r)
if marshalErr != nil || fileutil.AtomicWriteFile(path, updated, 0o600) != nil {
break
}
}
key := crashLedgerKey(r)
if _, alreadySent := ledger.Entries[key]; alreadySent {
_ = os.Remove(path)
continue
}
if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
break
}
ledger.Entries[key] = time.Now().UTC().Format(time.RFC3339Nano)
if saveCrashLedger(ledgerPath, ledger) != nil {
break
}
_ = os.Remove(path)
}
_ = os.Remove(pendingCrashDir())
+230
View File
@@ -3,13 +3,17 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func readPending(t *testing.T) (crashReport, bool) {
@@ -245,3 +249,229 @@ func TestPendingCrashDoesNotPersistInstallID(t *testing.T) {
t.Fatalf("pending report persisted an installation identity: %s", body)
}
}
func TestFlushPendingCrashDeduplicatesSameVersionAndResendsAfterUpgrade(t *testing.T) {
removeAllPendingCrashes()
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
removeAllPendingCrashes()
})
version = "v9.9.9"
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
crashEndpoint = srv.URL
report := baseCrashReport("crash")
report.Source = "go"
report.ErrorType = "panic"
report.TopFrame = "main.go:12"
report.Message = "same crash"
firstQueued := writePendingReport(report, false)
secondQueued := writePendingReport(report, false)
if !firstQueued || !secondQueued {
t.Fatal("failed to queue duplicate reports")
}
NewApp().flushPendingCrash()
if got := hits.Load(); got != 1 {
t.Fatalf("same-version uploads = %d, want 1", got)
}
report.Version = "v10.0.0"
report.EventID, report.DedupKey = "", ""
if !writePendingReport(report, false) {
t.Fatal("failed to queue upgraded report")
}
NewApp().flushPendingCrash()
if got := hits.Load(); got != 2 {
t.Fatalf("uploads after version upgrade = %d, want 2", got)
}
info, err := os.Stat(crashLedgerPath())
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("ledger permissions = %o, want 600", info.Mode().Perm())
}
}
func TestConcurrentFlushPendingCrashUsesOneCrossProcessLedgerOwner(t *testing.T) {
removeAllPendingCrashes()
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
removeAllPendingCrashes()
})
version = "v9.9.9"
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
crashEndpoint = srv.URL
writePendingCrash("concurrent", "boom", []byte("stack"))
start := make(chan struct{})
var done sync.WaitGroup
for range 2 {
done.Go(func() {
<-start
NewApp().flushPendingCrash()
})
}
close(start)
done.Wait()
if got := hits.Load(); got != 1 {
t.Fatalf("concurrent uploads = %d, want 1", got)
}
}
func TestFlushPendingCrashFailureDoesNotRecordOrDelete(t *testing.T) {
removeAllPendingCrashes()
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
removeAllPendingCrashes()
})
version = "v9.9.9"
status := atomic.Int32{}
status.Store(http.StatusServiceUnavailable)
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(int(status.Load()))
}))
defer srv.Close()
crashEndpoint = srv.URL
writePendingCrash("retry", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if _, ok := readPending(t); !ok {
t.Fatal("failed upload removed pending crash")
}
if ledger := loadCrashLedger(crashLedgerPath(), time.Now().UTC()); len(ledger.Entries) != 0 {
t.Fatalf("failed upload recorded ledger entry: %+v", ledger.Entries)
}
status.Store(http.StatusAccepted)
NewApp().flushPendingCrash()
if hits.Load() != 2 {
t.Fatalf("retry requests = %d, want 2", hits.Load())
}
if _, ok := readPending(t); ok {
t.Fatal("successful retry left pending crash")
}
}
func TestFlushPendingCrashBackfillsOldIdentityAndPreservesFutureSchema(t *testing.T) {
removeAllPendingCrashes()
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
removeAllPendingCrashes()
})
version = "v9.9.9"
if err := os.MkdirAll(pendingCrashDir(), 0o700); err != nil {
t.Fatal(err)
}
oldPath := filepath.Join(pendingCrashDir(), "001-old.json")
futurePath := filepath.Join(pendingCrashDir(), "002-future.json")
oldReport := `{"kind":"crash","version":"v9.9.9","os":"linux","arch":"amd64","message":"old","schemaVersion":2,"source":"go","errorType":"panic","topFrame":"main.go:12"}`
futureReport := `{"kind":"crash","version":"v10.0.0","os":"linux","arch":"amd64","message":"future","schemaVersion":99,"futureField":"keep"}`
if err := os.WriteFile(oldPath, []byte(oldReport), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(futurePath, []byte(futureReport), 0o600); err != nil {
t.Fatal(err)
}
var uploaded crashReport
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&uploaded); err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
crashEndpoint = srv.URL
NewApp().flushPendingCrash()
if len(uploaded.EventID) != 32 || len(uploaded.DedupKey) != 64 {
t.Fatalf("old report identity not backfilled: %+v", uploaded)
}
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("old report was not removed after send: %v", err)
}
body, err := os.ReadFile(futurePath)
if err != nil || !bytes.Contains(body, []byte(`"futureField":"keep"`)) {
t.Fatalf("future schema was not preserved: body=%s err=%v", body, err)
}
}
func TestFlushPendingCrashUploadsCurrentSchemaThree(t *testing.T) {
removeAllPendingCrashes()
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
removeAllPendingCrashes()
})
version = "v9.9.9"
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
crashEndpoint = srv.URL
report := desktopLifecycleReport(desktopLifecycleObservation{
Version: "v9.9.8",
Phase: "ready",
})
if report.SchemaVersion != currentCrashSchema {
t.Fatalf("current producer schema = %d, supported = %d", report.SchemaVersion, currentCrashSchema)
}
if !writePendingReport(report, false) {
t.Fatal("failed to queue current-schema report")
}
NewApp().flushPendingCrash()
if got := hits.Load(); got != 1 {
t.Fatalf("current-schema uploads = %d, want 1", got)
}
if got := len(pendingCrashPaths()); got != 0 {
t.Fatalf("pending reports after upload = %d, want 0", got)
}
}
func TestWritePendingReportPrunesKnownReportsWithoutDeletingFutureSchema(t *testing.T) {
removeAllPendingCrashes()
t.Cleanup(removeAllPendingCrashes)
if err := os.MkdirAll(pendingCrashDir(), 0o700); err != nil {
t.Fatal(err)
}
futurePath := filepath.Join(pendingCrashDir(), "000-future.json")
futureReport := `{"schemaVersion":99,"futureField":"keep"}`
if err := os.WriteFile(futurePath, []byte(futureReport), 0o600); err != nil {
t.Fatal(err)
}
for index := range maxPendingCrashes {
report := baseCrashReport("crash")
report.SchemaVersion = currentCrashSchema
report.Source = "go"
report.Label = fmt.Sprintf("panic-%d", index)
report.Message = "bounded current report"
if !writePendingReport(report, false) {
t.Fatalf("failed to queue current report %d", index)
}
}
body, err := os.ReadFile(futurePath)
if err != nil || !bytes.Contains(body, []byte(`"futureField":"keep"`)) {
t.Fatalf("future schema was pruned: body=%s err=%v", body, err)
}
if got := len(pendingCrashQueuePaths()); got != maxPendingCrashes {
t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
}
}
+365
View File
@@ -0,0 +1,365 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"sync"
"time"
"reasonix/internal/config"
)
// Local-proxy mode tunnels model calls to this desktop, which swaps a scoped
// virtual token for the real provider key. The real key never leaves desktop.
// credentialProxyProviderName is the provider entry the bootstrap installs in
// the remote config; the serve launches with --model <name>.
const credentialProxyProviderName = "reasonix-desktop-proxy"
type credProxyRoute struct {
proxy *httputil.ReverseProxy
model string
}
// credentialProxy is the desktop-side key holder: a loopback HTTP endpoint
// that authenticates requests by virtual token and forwards them to the real
// provider with the real key. One instance serves the whole app.
type credentialProxy struct {
mu sync.Mutex
ln net.Listener
server *http.Server
port int
routes map[string]*credProxyRoute
}
func (p *credentialProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Unauthenticated liveness endpoint for the desktop's reverse-tunnel
// probe: the listener only exists behind the SSH reverse forward, so a
// 204 here proves serve → remote loopback → tunnel → desktop end to end.
if r.URL.Path == "/healthz" {
w.WriteHeader(http.StatusNoContent)
return
}
token := bearerToken(r.Header.Get("Authorization"))
p.mu.Lock()
route := p.routes[token]
routeCount := len(p.routes)
p.mu.Unlock()
if route == nil {
log.Printf("[remote] credProxy: rejected %s %s routeCount=%d", r.Method, r.URL.Path, routeCount)
http.Error(w, "invalid credential proxy token", http.StatusUnauthorized)
return
}
if route.model != "" && r.Body != nil && (r.Method == http.MethodPost || r.Method == http.MethodPut) {
const rewriteLimit = 64 << 20
if r.ContentLength > rewriteLimit {
http.Error(w, "credential proxy request body is too large", http.StatusRequestEntityTooLarge)
return
}
buffered, err := io.ReadAll(io.LimitReader(r.Body, rewriteLimit+1))
switch {
case err != nil:
_ = r.Body.Close()
http.Error(w, "credential proxy could not read request body", http.StatusBadRequest)
return
case int64(len(buffered)) > rewriteLimit:
_ = r.Body.Close()
http.Error(w, "credential proxy request body is too large", http.StatusRequestEntityTooLarge)
return
default:
_ = r.Body.Close()
body := rewriteJSONModel(buffered, route.model)
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
r.Header.Set("Content-Length", strconv.Itoa(len(body)))
}
}
route.proxy.ServeHTTP(w, r)
}
func rewriteJSONModel(body []byte, model string) []byte {
if model == "" || len(body) == 0 {
return body
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil || payload == nil {
// Unparseable or a literal null body ("null" decodes into a nil
// map): assigning into nil would panic, and there is nothing to
// rewrite — pass the body through untouched.
return body
}
payload["model"] = model
out, err := json.Marshal(payload)
if err != nil {
return body
}
return out
}
func (p *credentialProxy) setRoute(token string, upstream *url.URL, apiKey, model, kind string) {
if kind == "" {
kind = "openai"
}
proxy := &httputil.ReverseProxy{FlushInterval: -1}
proxy.Rewrite = func(req *httputil.ProxyRequest) {
req.SetURL(upstream)
for _, header := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "Via"} {
req.Out.Header.Del(header)
}
if kind == "anthropic" {
req.Out.Header.Del("Authorization")
req.Out.Header.Set("x-api-key", apiKey)
req.Out.Header.Set("anthropic-version", "2023-06-01")
} else {
req.Out.Header.Del("x-api-key")
req.Out.Header.Set("Authorization", "Bearer "+apiKey)
}
}
p.mu.Lock()
defer p.mu.Unlock()
p.routes[token] = &credProxyRoute{proxy: proxy, model: model}
}
func (p *credentialProxy) close() {
p.mu.Lock()
server, listener := p.server, p.ln
p.server, p.ln = nil, nil
p.mu.Unlock()
if server != nil {
_ = server.Close()
}
if listener != nil {
_ = listener.Close()
}
}
func bearerToken(header string) string {
prefix, value, ok := strings.Cut(strings.TrimSpace(header), " ")
if !ok || !strings.EqualFold(prefix, "Bearer") {
return ""
}
return strings.TrimSpace(value)
}
// credentialProxyPort returns the proxy's loopback port, starting the proxy
// on first use.
func (a *App) credentialProxyPort() (int, error) {
a.credProxyMu.Lock()
defer a.credProxyMu.Unlock()
if a.credProxy != nil {
return a.credProxy.port, nil
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("credential proxy: listen: %w", err)
}
p := &credentialProxy{ln: ln, port: ln.Addr().(*net.TCPAddr).Port, routes: map[string]*credProxyRoute{}}
server := &http.Server{
Handler: p,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 1 << 20,
}
p.server = server
a.credProxy = p
a.goSafe("credentialProxy", func() { _ = server.Serve(ln) })
return p.port, nil
}
func (a *App) closeCredentialProxy() {
a.credProxyMu.Lock()
defer a.credProxyMu.Unlock()
if a.credProxy != nil {
a.credProxy.close()
a.credProxy = nil
}
}
// credentialProxySecret loads (creating on first use) the persisted random
// secret every virtual token derives from. Rotating it revokes all tokens.
func (a *App) credentialProxySecret() (string, error) {
remotePrefsMu.Lock()
defer remotePrefsMu.Unlock()
p, err := updateRemotePrefsLocked(func(p *remotePrefs) (bool, error) {
if p.CredentialProxySecret != "" {
return false, nil
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return false, fmt.Errorf("credential proxy: generate secret: %w", err)
}
p.CredentialProxySecret = hex.EncodeToString(buf)
return true, nil
})
if err != nil {
return "", fmt.Errorf("credential proxy: persist secret: %w", err)
}
return p.CredentialProxySecret, nil
}
// credentialProxyModelTokenFor gives each staged model an immutable route.
// A controller already running with the previous virtual token therefore keeps
// its old upstream for the whole turn while Serve builds and publishes the new
// controller. This is the cross-process half of failure-atomic model switches.
func credentialProxyModelTokenFor(secret, hostID, workspace, modelRef string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte("reasonix-credential-proxy-model:v2"))
for _, field := range []string{hostID, workspace, modelRef} {
var size [8]byte
binary.BigEndian.PutUint64(size[:], uint64(len(field)))
_, _ = mac.Write(size[:])
_, _ = mac.Write([]byte(field))
}
return hex.EncodeToString(mac.Sum(nil))[:32]
}
func (a *App) credentialProxyModelToken(hostID, workspace, modelRef string) (string, error) {
secret, err := a.credentialProxySecret()
if err != nil {
return "", err
}
return credentialProxyModelTokenFor(secret, hostID, workspace, modelRef), nil
}
// credentialProxyRouteInfo is everything a serve bootstrap needs to install
// the desktop hop on the remote: the virtual token, the model name and
// provider kind the remote provider entry should carry, and the proxy's
// loopback port.
type credentialProxyRouteInfo struct {
token string
model string
kind string
port int
}
// proxyUpstream is the resolved desktop-side provider a route forwards to.
type proxyUpstream struct {
apiKey string
url *url.URL
model string
kind string
}
// resolveProxyProvider resolves a desktop model ref into the upstream the
// credential proxy should forward to, including the auth-header shape its
// provider kind expects.
func resolveProxyProvider(cfg *config.Config, ref string) (proxyUpstream, error) {
entry, ok := cfg.ResolveModel(ref)
if !ok {
return proxyUpstream{}, fmt.Errorf("credential proxy: model %q has no provider", ref)
}
apiKey := config.ResolveCredential(entry.APIKeyEnv).Value
if apiKey == "" {
return proxyUpstream{}, fmt.Errorf("credential proxy: the local provider credential is not configured")
}
base := strings.TrimSpace(entry.BaseURL)
if base == "" {
base = "https://api.openai.com"
}
upstream, err := url.Parse(strings.TrimRight(base, "/") + "/")
if err != nil {
return proxyUpstream{}, fmt.Errorf("credential proxy: provider base_url: %w", err)
}
if (upstream.Scheme != "http" && upstream.Scheme != "https") || upstream.Host == "" || upstream.User != nil || upstream.Fragment != "" {
return proxyUpstream{}, fmt.Errorf("credential proxy: provider base_url must be an http(s) URL without credentials or a fragment")
}
kind := strings.TrimSpace(entry.Kind)
if kind == "" {
kind = "openai"
}
return proxyUpstream{apiKey: apiKey, url: upstream, model: entry.Model, kind: kind}, nil
}
// registerCredentialProxyRoute binds one workspace token to the current
// desktop default provider without exposing its real key to the remote.
func (a *App) registerCredentialProxyRoute(hostID, workspace string) (credentialProxyRouteInfo, error) {
cfg, err := config.Load()
if err != nil {
return credentialProxyRouteInfo{}, err
}
ref := strings.TrimSpace(cfg.DefaultModel)
if workspaceModel := a.desktopModelForWorkspace(hostID, workspace); workspaceModel != "" {
ref = workspaceModel
}
return a.applyCredentialProxyModel(hostID, workspace, ref)
}
// desktopModelForWorkspace deterministically selects the newest tab-owned
// model for a workspace; map iteration order must never choose a route.
func (a *App) desktopModelForWorkspace(hostID, workspace string) string {
a.remoteTabMu.Lock()
defer a.remoteTabMu.Unlock()
var selected string
var selectedSeq uint64
for _, tab := range a.remoteTabs {
if tab == nil || tab.ref.HostID != hostID || tab.ref.Workspace != workspace || strings.TrimSpace(tab.model) == "" {
continue
}
if tab.modelSeq >= selectedSeq {
selected, selectedSeq = tab.model, tab.modelSeq
}
}
return selected
}
func (a *App) applyCredentialProxyModel(hostID, workspace, ref string) (credentialProxyRouteInfo, error) {
cfg, err := config.Load()
if err != nil {
return credentialProxyRouteInfo{}, err
}
up, err := resolveProxyProvider(cfg, ref)
if err != nil {
return credentialProxyRouteInfo{}, err
}
port, err := a.credentialProxyPort()
if err != nil {
return credentialProxyRouteInfo{}, err
}
a.credProxyMu.Lock()
proxy := a.credProxy
a.credProxyMu.Unlock()
if proxy == nil {
return credentialProxyRouteInfo{}, fmt.Errorf("credential proxy: not running")
}
// Route tokens include the canonical desktop model ref. Never mutate the
// route held by an in-flight controller during a model switch.
token, err := a.credentialProxyModelToken(hostID, workspace, ref)
if err != nil {
return credentialProxyRouteInfo{}, err
}
proxy.setRoute(token, up.url, up.apiKey, up.model, up.kind)
return credentialProxyRouteInfo{token: token, model: up.model, kind: up.kind, port: port}, nil
}
// credentialModeView returns the host entry's normalized credential mode for
// views ("" reads as "remote" — the default).
func credentialModeView(h config.RemoteHostEntry) string {
if h.CredentialProxyEnabled() {
return "local-proxy"
}
return "remote"
}
// normalizeCredentialMode validates an input credential mode.
func normalizeCredentialMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "local-proxy":
return "local-proxy"
default:
return ""
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/crypto/ssh"
"reasonix/internal/remote/forward"
"reasonix/internal/remote/sshtest"
)
// TestCredentialProxyReverseForwardEndToEnd drives the real SSH forwarding
// protocol against an sshtest server: the "remote" loopback listener forwards
// connections back through the SSH channel to the desktop-side target, which
// is exactly the path local-proxy model calls take. The helper is idempotent.
func TestCredentialProxyReverseForwardEndToEnd(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("proxy-ok"))
}))
defer target.Close()
targetPort := target.Listener.Addr().(*net.TCPAddr).Port
srv := sshtest.Start(t, sshtest.Options{})
cfg := &ssh.ClientConfig{User: "t", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}
cl, err := ssh.Dial("tcp", srv.Addr, cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cl.Close() })
set := forward.NewSet(nil)
set.Attach(cl)
lc := newLifecycleSSHClient(nil)
lc.forwards = set
remotePort, err := ensureCredentialProxyForward(lc, "box", targetPort)
if err != nil {
t.Fatal(err)
}
if remotePort <= 0 || remotePort > 65535 {
t.Fatalf("remote port = %d, want an ephemeral bound port", remotePort)
}
// Idempotent: a second ensure neither errors nor adds a second forward,
// and reports the SAME bound port.
again, err := ensureCredentialProxyForward(lc, "box", targetPort)
if err != nil {
t.Fatalf("second ensure: %v", err)
}
if again != remotePort {
t.Fatalf("second ensure port = %d, want %d", again, remotePort)
}
if n := len(set.List()); n != 1 {
t.Fatalf("forward count = %d, want 1", n)
}
// Dial the REMOTE-side bind address. sshtest runs on localhost, so the
// remote loopback port is reachable here; the bytes travel
// dial → ssh server → forwarded-tcpip → ssh client → target.
resp, err := (&http.Client{Timeout: 5 * time.Second}).Get(fmt.Sprintf("http://127.0.0.1:%d/hello", remotePort))
if err != nil {
t.Fatalf("reverse forward dial: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != "proxy-ok" {
t.Fatalf("body = %q, want proxy-ok", body)
}
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"time"
)
// TestCredentialProxyRealHostSmoke is a manual E2E against a real SSH host:
// set CRED_PROXY_SMOKE_HOST=<hostID> (whose config entry has
// credential_mode = "local-proxy") to run. It drives the full desktop path —
// connect, reverse tunnel, credential proxy, serve launch with the virtual
// token — then submits one real model turn and asserts the reply arrived
// through desktop-held credentials. Costs one tiny provider call.
func TestCredentialProxyRealHostSmoke(t *testing.T) {
hostID := os.Getenv("CRED_PROXY_SMOKE_HOST")
if hostID == "" {
t.Skip("set CRED_PROXY_SMOKE_HOST=<hostID with credential_mode=local-proxy> to run the real-host smoke")
}
// The desktop test binary's TestMain redirects HOME to a scratch dir; the
// smoke needs the real user config (hosts, provider, .env key). Point the
// config root back at the real ~/.reasonix for this test only.
if real, err := user.Current(); err == nil && real.HomeDir != "" {
t.Setenv("HOME", real.HomeDir)
t.Setenv("REASONIX_HOME", filepath.Join(real.HomeDir, ".reasonix"))
} else {
t.Fatal("cannot resolve the real home directory")
}
workspace := os.Getenv("CRED_PROXY_SMOKE_WORKSPACE")
if workspace == "" {
workspace = "/root/smoke-a"
}
a := &App{ctx: context.Background()}
mgr := newDesktopRemoteManager(a)
a.remoteRuntime = mgr
t.Cleanup(func() { _ = mgr.Close(); a.closeCredentialProxy() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := mgr.Connect(hostID); err != nil {
t.Fatalf("connect: %v", err)
}
if err := waitForRemoteHostSmoke(mgr, hostID, 60*time.Second); err != nil {
t.Fatalf("host never connected: %v", err)
}
view, token, err := mgr.EnsureServer(ctx, hostID, workspace)
if err != nil {
t.Fatalf("EnsureServer: %v", err)
}
if view.State != "ready" || view.LocalURL == "" {
t.Fatalf("serve view = %+v", view)
}
base := strings.TrimRight(view.LocalURL, "/")
t.Logf("serve ready at %s (workspace %s)", base, workspace)
// Verify the reverse tunnel is registered and the remote provider entry
// points at it.
foundForward := false
for _, f := range mgr.client(hostID).Forwards().List() {
if f.Spec.Name == "cred-proxy:"+hostID && f.Up {
foundForward = true
}
}
if !foundForward {
t.Fatal("credential proxy reverse forward is not up")
}
// One real model turn through the desktop-held key.
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar, Timeout: 120 * time.Second}
if err := serveHandshake(ctx, client, base, token); err != nil {
t.Fatalf("handshake: %v", err)
}
post := func(path string, body any) (int, []byte) {
data, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, base+path, strings.NewReader(string(data)))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, out
}
if code, body := post("/new", map[string]string{}); code != 204 {
t.Fatalf("/new: %d %s", code, body)
}
if code, body := post("/submit", map[string]string{"input": "Reply with exactly the word PROXY-OK and nothing else."}); code != 202 {
t.Fatalf("/submit: %d %s", code, body)
}
// Poll history for the assistant reply (the turn rides the tunnel).
deadline := time.Now().Add(90 * time.Second)
for {
resp, err := client.Get(base + "/history")
if err == nil {
out, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if strings.Contains(string(out), "PROXY-OK") {
t.Logf("model replied through the desktop credential proxy")
return
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("/history: %d %s", resp.StatusCode, out)
}
}
if time.Now().After(deadline) {
t.Fatalf("no PROXY-OK reply within 90s (last history fetch err=%v)", err)
}
time.Sleep(2 * time.Second)
}
}
func waitForRemoteHostSmoke(rt remoteKernel, hostID string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
for _, status := range rt.Statuses() {
if status.HostID != hostID {
continue
}
switch status.State {
case "connected", "degraded":
return nil
case "stopped":
if status.Error != "" {
return fmt.Errorf("remote host %q: %s", hostID, status.Error)
}
return fmt.Errorf("remote host %q stopped", hostID)
}
}
if time.Now().After(deadline) {
return fmt.Errorf("remote host %q: connection timed out", hostID)
}
time.Sleep(250 * time.Millisecond)
}
}
+424
View File
@@ -0,0 +1,424 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/remote/bootstrap"
)
type failingRequestBody struct{}
func (failingRequestBody) Read([]byte) (int, error) { return 0, errors.New("read failed") }
func (failingRequestBody) Close() error { return nil }
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(strings.TrimRight(raw, "/") + "/")
if err != nil {
t.Fatal(err)
}
return u
}
// TestCredentialProxyAuthSwap covers the desktop key holder over real HTTP:
// the registered virtual token forwards to the provider with the real key,
// anything else is rejected without reaching the provider.
func TestCredentialProxyAuthSwap(t *testing.T) {
var gotAuth, gotForwarded string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
for _, h := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "Via"} {
gotForwarded += r.Header.Get(h)
}
_, _ = w.Write([]byte("model-ok"))
}))
defer upstream.Close()
seedBridgeTestHost(t, "box")
a := &App{}
t.Cleanup(a.closeCredentialProxy)
port, err := a.credentialProxyPort()
if err != nil {
t.Fatal(err)
}
const token = "virtual-tok"
a.credProxy.setRoute(token, mustParseURL(t, upstream.URL), "sk-real-key", "", "")
proxyURL := fmt.Sprintf("http://127.0.0.1:%d/v1/chat", port)
do := func(auth string) (int, string) {
req, err := http.NewRequest(http.MethodPost, proxyURL, strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", auth)
req.Header.Set("Connection", "Authorization")
req.Header.Set("Forwarded", "for=attacker")
req.Header.Set("X-Forwarded-For", "203.0.113.9")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
buf := make([]byte, 64)
n, _ := resp.Body.Read(buf)
return resp.StatusCode, string(buf[:n])
}
if code, body := do("Bearer virtual-tok"); code != 200 || body != "model-ok" {
t.Fatalf("valid token: code=%d body=%q", code, body)
}
if gotAuth != "Bearer sk-real-key" {
t.Fatalf("upstream auth = %q, want the real key", gotAuth)
}
if gotForwarded != "" {
t.Fatalf("forwarding identity leaked upstream: %q", gotForwarded)
}
if code, _ := do("Bearer wrong"); code != 401 {
t.Fatalf("wrong token: code=%d, want 401", code)
}
if gotAuth != "Bearer sk-real-key" {
t.Fatalf("rejected request reached the upstream: %q", gotAuth)
}
if code, _ := do(""); code != 401 {
t.Fatalf("missing token: code=%d, want 401", code)
}
}
// TestCredentialProxyRewritesRequestModel: desktop owns the current model, so
// the proxy replaces the serve's request-body model with the desktop selection
// before the real provider sees it. The provider must also see its OWN host
// in the Host header — the inbound loopback host must not leak through
// (CloudFront-fronted APIs 403 a foreign Host).
func TestCredentialProxyRewritesRequestModel(t *testing.T) {
var gotBody, gotHost string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf, _ := io.ReadAll(r.Body)
gotBody = string(buf)
gotHost = r.Host
_, _ = w.Write([]byte("model-ok"))
}))
defer upstream.Close()
seedBridgeTestHost(t, "box")
a := &App{}
t.Cleanup(a.closeCredentialProxy)
port, err := a.credentialProxyPort()
if err != nil {
t.Fatal(err)
}
const token = "virtual-tok"
a.credProxy.setRoute(token, mustParseURL(t, upstream.URL), "sk-real-key", "deepseek-v4-pro", "openai")
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", port), strings.NewReader(`{"model":"deepseek-v4-flash","messages":[]}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer virtual-tok")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if !strings.Contains(gotBody, `"model":"deepseek-v4-pro"`) {
t.Fatalf("upstream body = %q, want rewritten model deepseek-v4-pro", gotBody)
}
if strings.Contains(gotBody, "deepseek-v4-flash") {
t.Fatalf("upstream still saw the serve's model: %q", gotBody)
}
if want := strings.TrimPrefix(strings.TrimPrefix(upstream.URL, "http://"), "http://"); gotHost != want {
t.Fatalf("upstream Host = %q, want the upstream's own host %q", gotHost, want)
}
}
func TestCredentialProxyRejectsUnreadableOrOversizeBodies(t *testing.T) {
upstreamCalls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
upstreamCalls++
}))
defer upstream.Close()
p := &credentialProxy{routes: map[string]*credProxyRoute{}}
p.setRoute("virtual-tok", mustParseURL(t, upstream.URL), "sk-real-key", "model", "openai")
request := func(body io.ReadCloser, contentLength int64) int {
req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/v1/chat/completions", body)
req.Header.Set("Authorization", "Bearer virtual-tok")
req.ContentLength = contentLength
recorder := httptest.NewRecorder()
p.ServeHTTP(recorder, req)
return recorder.Code
}
if code := request(failingRequestBody{}, -1); code != http.StatusBadRequest {
t.Fatalf("unreadable body status = %d, want 400", code)
}
if code := request(io.NopCloser(strings.NewReader("{}")), (64<<20)+1); code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversize body status = %d, want 413", code)
}
if upstreamCalls != 0 {
t.Fatalf("invalid bodies reached upstream %d times", upstreamCalls)
}
}
// TestCredentialProxyTokenStableAcrossRestarts: the virtual token derives
// from a persisted secret plus host/workspace/model identity, so a restarted
// desktop keeps the same route while distinct workspaces stay isolated.
func TestCredentialProxyTokenStableAcrossRestarts(t *testing.T) {
seedBridgeTestHost(t, "box")
a1 := &App{}
t.Cleanup(a1.closeCredentialProxy)
i1, err := a1.registerCredentialProxyRoute("box", "~/app")
if err != nil {
t.Fatal(err)
}
a2 := &App{}
t.Cleanup(a2.closeCredentialProxy)
i2, err := a2.registerCredentialProxyRoute("box", "~/app")
if err != nil {
t.Fatal(err)
}
if i1.token == "" || i1.token != i2.token {
t.Fatalf("token drifted across App instances: %q vs %q", i1.token, i2.token)
}
i3, err := a2.registerCredentialProxyRoute("other", "~/app")
if err != nil {
t.Fatal(err)
}
if i3.token == i1.token {
t.Fatalf("different hosts share a token: %q", i1.token)
}
i4, err := a2.registerCredentialProxyRoute("box", "~/other")
if err != nil {
t.Fatal(err)
}
if i4.token == i1.token {
t.Fatalf("different workspaces share a token: %q", i1.token)
}
}
func TestCredentialProxyModelTokensKeepRoutesImmutable(t *testing.T) {
secret := strings.Repeat("ab", 32)
one := credentialProxyModelTokenFor(secret, "box", "~/app", "provider/model-a")
two := credentialProxyModelTokenFor(secret, "box", "~/app", "provider/model-b")
again := credentialProxyModelTokenFor(secret, "box", "~/app", "provider/model-a")
if one == two {
t.Fatal("different models shared one mutable credential proxy route token")
}
if one != again {
t.Fatal("the same model route token was not stable across registration")
}
if collision := credentialProxyModelTokenFor(secret, "box", "~/app", "provider:model-a"); collision == credentialProxyModelTokenFor(secret, "box", "~/app:provider", "model-a") {
t.Fatal("length-framed route identities shared a token")
}
}
func TestCredentialProxyReconnectRegistersTrackedWorkspaces(t *testing.T) {
seedBridgeTestHost(t, "box")
app := &App{}
t.Cleanup(app.closeCredentialProxy)
mgr := newDesktopRemoteManager(app)
mgr.hosts["box"] = &managedHost{serves: map[string]*serveEntry{
"~/app": {},
"~/other": {},
}}
info, err := mgr.registerTrackedCredentialRoutes(app, "box", "~/app")
if err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
otherToken, err := app.credentialProxyModelToken("box", "~/other", cfg.DefaultModel)
if err != nil {
t.Fatal(err)
}
app.credProxy.mu.Lock()
defer app.credProxy.mu.Unlock()
if info.token == "" || app.credProxy.routes[info.token] == nil || app.credProxy.routes[otherToken] == nil {
t.Fatalf("tracked routes were not registered together: current=%q count=%d", info.token, len(app.credProxy.routes))
}
}
func TestCredentialWatchdogHealsEveryTrackedWorkspace(t *testing.T) {
mgr := newDesktopRemoteManager(nil)
mgr.hosts["box"] = &managedHost{serves: map[string]*serveEntry{
"~/alpha": {},
"~/beta": {},
"~/gamma": {},
}}
workspaces := mgr.trackedCredentialWorkspaces("box", "~/beta")
want := []string{"~/beta", "~/alpha", "~/gamma"}
if !slices.Equal(workspaces, want) {
t.Fatalf("tracked credential workspaces = %v, want %v", workspaces, want)
}
var setupCalls, healCalls []string
err := healTrackedCredentialProviders(context.Background(), workspaces,
func(workspace string) (*bootstrap.CredentialProxyOptions, error) {
setupCalls = append(setupCalls, workspace)
return &bootstrap.CredentialProxyOptions{Provider: workspace}, nil
},
func(_ context.Context, opts *bootstrap.CredentialProxyOptions) error {
healCalls = append(healCalls, opts.Provider)
return nil
},
)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(setupCalls, want) || !slices.Equal(healCalls, want) {
t.Fatalf("credential heals = setup:%v heal:%v, want every workspace %v", setupCalls, healCalls, want)
}
}
func TestCredentialEnsureHealsEveryConfigBeforeReload(t *testing.T) {
workspaces := []string{"~/current", "~/peer"}
var calls []string
err := healCredentialConfigsBeforeReload(t.Context(), workspaces,
func(workspace string) (*bootstrap.CredentialProxyOptions, error) {
calls = append(calls, "setup:"+workspace)
return &bootstrap.CredentialProxyOptions{Provider: workspace}, nil
},
func(_ context.Context, opts *bootstrap.CredentialProxyOptions) error {
calls = append(calls, "heal:"+opts.Provider)
return nil
},
func() bool {
calls = append(calls, "reload")
return true
},
)
if err != nil {
t.Fatal(err)
}
want := []string{"setup:~/current", "heal:~/current", "setup:~/peer", "heal:~/peer", "reload"}
if !slices.Equal(calls, want) {
t.Fatalf("ensure heal order = %v, want %v", calls, want)
}
}
func TestEnsureServerRejectsRemovedHost(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
client := newLifecycleSSHClient(nil)
mgr := newDesktopRemoteManager(nil)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
mgr.hosts["removed"] = &managedHost{ctx: ctx, cancel: cancel, client: client, serves: map[string]*serveEntry{}}
called := false
mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
called = true
return bootstrap.Result{}, nil
}
if _, _, err := mgr.EnsureServer(context.Background(), "removed", "~/app"); err == nil || !strings.Contains(err.Error(), "no longer configured") {
t.Fatalf("EnsureServer removed host error = %v", err)
}
if called {
t.Fatal("removed host reached remote bootstrap")
}
}
// TestCredentialProxyAnthropicAuthShape: an anthropic-kind route swaps the
// virtual token for x-api-key (+ anthropic-version) instead of a bearer
// header.
func TestCredentialProxyAnthropicAuthShape(t *testing.T) {
var gotKey, gotVersion, gotAuth string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey = r.Header.Get("x-api-key")
gotVersion = r.Header.Get("anthropic-version")
gotAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte("ok"))
}))
defer upstream.Close()
seedBridgeTestHost(t, "box")
a := &App{}
t.Cleanup(a.closeCredentialProxy)
port, err := a.credentialProxyPort()
if err != nil {
t.Fatal(err)
}
a.credProxy.setRoute("virtual-tok", mustParseURL(t, upstream.URL), "sk-real-key", "", "anthropic")
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/messages", port), strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer virtual-tok")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if gotKey != "sk-real-key" {
t.Fatalf("x-api-key = %q, want the real key", gotKey)
}
if gotVersion == "" {
t.Fatal("anthropic-version header missing")
}
if gotAuth != "" {
t.Fatalf("Authorization header leaked to the anthropic upstream: %q", gotAuth)
}
}
// TestRewriteJSONModelGuards: a literal null body must pass through without
// panicking (assigning into a nil map would), and a non-JSON body stays
// untouched.
func TestRewriteJSONModelGuards(t *testing.T) {
if got := rewriteJSONModel([]byte("null"), "m"); string(got) != "null" {
t.Fatalf("null body rewritten: %q", got)
}
if got := rewriteJSONModel([]byte("not json"), "m"); string(got) != "not json" {
t.Fatalf("non-JSON body rewritten: %q", got)
}
if got := rewriteJSONModel([]byte(`{"model":"a"}`), ""); string(got) != `{"model":"a"}` {
t.Fatalf("empty model rewrote the body: %q", got)
}
if got := rewriteJSONModel([]byte(`{"model":"a"}`), "b"); !strings.Contains(string(got), `"model":"b"`) {
t.Fatalf("model not rewritten: %q", got)
}
}
// TestCredentialModeConfigRoundTrip pins the host entry field end to end.
func TestCredentialModeConfigRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
if err := editUserConfig(func(c *config.Config) error {
return c.UpsertRemoteHost(config.RemoteHostEntry{
Name: "p", Host: "127.0.0.1", CredentialMode: "local-proxy",
})
}); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
entry, ok := cfg.RemoteHost("p")
if !ok || !entry.CredentialProxyEnabled() {
t.Fatalf("credential mode did not round-trip: %+v", entry)
}
if v := credentialModeView(entry); v != "local-proxy" {
t.Fatalf("view mode = %q", v)
}
if n := normalizeCredentialMode("bogus"); n != "" {
t.Fatalf("bogus mode normalized to %q", n)
}
}
@@ -0,0 +1,208 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
? path.join(frontendDir, ".pw-browsers")
: process.env.PLAYWRIGHT_BROWSERS_PATH;
const { chromium } = await import("playwright");
const styles = fs.readFileSync(path.join(frontendDir, "src", "styles.css"), "utf8");
const themeStyles = ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"];
const modes = [
{ name: "forced dark", attribute: "dark", media: "dark" },
{ name: "forced light", attribute: "light", media: "dark" },
{ name: "auto dark", attribute: null, media: "dark" },
{ name: "auto light", attribute: null, media: "light" },
];
const overlaySelectors = [
".anchored-popover",
".slashmenu",
".composer-access-menu",
".jobs-popover",
".modelsw__menu",
".workspace-recent-menu",
".floating-menu",
".mem-ws-select__menu",
".menu",
".sound-select__menu",
".settings-model-picker__menu",
".external-opener__menu",
".context-menu",
".theme-gallery__menu",
".theme-gallery__editor",
".theme-editor__header",
".theme-editor__live",
".provider-access-more__menu",
".topicbar__more-menu",
".topicbar__more-export-menu",
".heartbeat-filter-menu",
".heartbeat-project-menu",
".heartbeat-task-menu",
];
const surfaceSelectors = [
".msg-pasted-block",
".composer__pasted-block",
".mem-tab-select",
".mermaid-diagram__error",
];
const fixtures = [...new Set([...overlaySelectors, ...surfaceSelectors])]
.map((selector) => `<div class="${selector.slice(1)}">surface</div>`)
.join("");
const amberExpected = {
dark: {
"--bg": "#090a0c",
"--bg-soft": "#111319",
"--panel": "#191b22",
"--sidebar-bg": "#0c0e12",
"--fg": "#f4f5f7",
"--fg-dim": "#c0c4cc",
"--accent": "#d4632f",
"--border": "#343945",
},
light: {
"--bg": "#f7f8fb",
"--bg-soft": "#eef2f7",
"--panel": "#ffffff",
"--sidebar-bg": "#f3f6fa",
"--fg": "#111827",
"--fg-dim": "#4b5563",
"--accent": "#dd5b28",
"--border": "#d8dee8",
},
};
function assert(condition, message) {
if (!condition) throw new Error(message);
process.stdout.write(` PASS ${message}\n`);
}
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.setContent(`<!doctype html><html><head><style>${styles}</style></head><body>${fixtures}<div class="transcript__loading">muted</div></body></html>`);
for (const style of themeStyles) {
for (const mode of modes) {
await page.emulateMedia({ colorScheme: mode.media });
await page.evaluate(({ style, attribute }) => {
const root = document.documentElement;
root.setAttribute("data-theme-style", style);
if (attribute) root.setAttribute("data-theme", attribute);
else root.removeAttribute("data-theme");
root.removeAttribute("data-theme-pack");
root.removeAttribute("data-theme-has-bg");
root.removeAttribute("data-theme-scene");
root.style.removeProperty("--theme-pane-overlay-pct");
root.style.removeProperty("--theme-pane-task-overlay-pct");
}, { style, attribute: mode.attribute });
const result = await page.evaluate(({ overlaySelectors, surfaceSelectors }) => {
function colorAlpha(value) {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d");
context.clearRect(0, 0, 1, 1);
context.fillStyle = value;
context.fillRect(0, 0, 1, 1);
return context.getImageData(0, 0, 1, 1).data[3] / 255;
}
const alpha = (selector) => colorAlpha(getComputedStyle(document.querySelector(selector)).backgroundColor);
const muted = document.querySelector(".transcript__loading");
const probe = document.createElement("span");
probe.style.color = "var(--fg-faint)";
document.body.append(probe);
const mutedColor = getComputedStyle(muted).color;
const expectedMutedColor = getComputedStyle(probe).color;
probe.remove();
return {
overlays: Object.fromEntries(overlaySelectors.map((selector) => [selector, alpha(selector)])),
surfaces: Object.fromEntries(surfaceSelectors.map((selector) => [selector, alpha(selector)])),
mutedColor,
expectedMutedColor,
};
}, { overlaySelectors, surfaceSelectors });
for (const [selector, alpha] of Object.entries(result.overlays)) {
assert(alpha >= 0.999, `${style} ${mode.name} keeps ${selector} opaque (${alpha})`);
}
for (const [selector, alpha] of Object.entries(result.surfaces)) {
assert(alpha > 0, `${style} ${mode.name} resolves ${selector} background (${alpha})`);
}
assert(result.mutedColor === result.expectedMutedColor, `${style} ${mode.name} resolves muted text to --fg-faint`);
if (style === "amber") {
const expected = amberExpected[mode.media === "light" || mode.attribute === "light" ? "light" : "dark"];
const colors = await page.evaluate((entries) => {
const result = {};
for (const [token] of entries) {
const probe = document.createElement("span");
probe.style.backgroundColor = `var(${token})`;
document.body.append(probe);
result[token] = getComputedStyle(probe).backgroundColor;
probe.remove();
}
return result;
}, Object.entries(expected));
const expectedColors = await page.evaluate((entries) => {
const result = {};
for (const [token, value] of entries) {
const probe = document.createElement("span");
probe.style.backgroundColor = value;
document.body.append(probe);
result[token] = getComputedStyle(probe).backgroundColor;
probe.remove();
}
return result;
}, Object.entries(expected));
for (const token of Object.keys(expected)) {
assert(colors[token] === expectedColors[token], `Amber ${mode.name} ${token} matches preview (${colors[token]})`);
}
}
}
}
await page.emulateMedia({ colorScheme: "dark" });
for (const [paneOpacity, expectedAlpha] of [[0, 0.4], [0.5, 0.9], [1, 1]]) {
for (const scene of ["home", "task"]) {
const alphas = await page.evaluate(({ overlaySelectors, paneOpacity, scene }) => {
const root = document.documentElement;
const percentage = `${Math.min((paneOpacity + 0.4) * 100, 100)}%`;
root.setAttribute("data-theme-style", "graphite");
root.setAttribute("data-theme", "dark");
root.setAttribute("data-theme-pack", "surface-contract");
root.setAttribute("data-theme-has-bg", "true");
root.setAttribute("data-theme-scene", scene);
root.style.setProperty("--theme-pane-overlay-pct", percentage);
root.style.setProperty("--theme-pane-task-overlay-pct", percentage);
return Object.fromEntries(overlaySelectors.map((selector) => {
const value = getComputedStyle(document.querySelector(selector)).backgroundColor;
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d");
context.clearRect(0, 0, 1, 1);
context.fillStyle = value;
context.fillRect(0, 0, 1, 1);
return [selector, context.getImageData(0, 0, 1, 1).data[3] / 255];
}));
}, { overlaySelectors, paneOpacity, scene });
for (const [selector, alpha] of Object.entries(alphas)) {
assert(
Math.abs(alpha - expectedAlpha) <= 1 / 255,
`${scene} ${selector} follows paneOpacity ${paneOpacity} (${alpha})`,
);
}
}
}
process.stdout.write("\nTheme surface browser contract passed.\n");
} finally {
await browser?.close();
}
@@ -16,13 +16,15 @@ const maxFrameGapMs = Number(process.env.REASONIX_TRANSCRIPT_MAX_FRAME_GAP_MS ??
const p95FrameGapMs = Number(process.env.REASONIX_TRANSCRIPT_P95_FRAME_GAP_MS ?? 80);
const maxLongTaskMs = Number(process.env.REASONIX_TRANSCRIPT_MAX_LONG_TASK_MS ?? 250);
const totalLongTaskMs = Number(process.env.REASONIX_TRANSCRIPT_TOTAL_LONG_TASK_MS ?? 750);
const nativeThumbReplay = process.platform === "linux"
&& process.env.REASONIX_TRANSCRIPT_NATIVE_THUMB === "1";
function assert(condition, message) {
if (!condition) throw new Error(message);
process.stdout.write(` PASS ${message}\n`);
}
async function moveToOuterReaderGutter(page, transcript) {
async function moveToOuterReaderGutter(page, transcript, announce = true) {
const deadline = Date.now() + 5_000;
let point = null;
while (!point && Date.now() < deadline) {
@@ -46,7 +48,8 @@ async function moveToOuterReaderGutter(page, transcript) {
});
if (!point) await page.waitForTimeout(16);
}
assert(point != null, "outer reader wheel target resolves to visible row padding");
if (announce) assert(point != null, "outer reader wheel target resolves to visible row padding");
else if (point == null) throw new Error("outer reader wheel target is unavailable during traversal");
await page.mouse.move(point.x, point.y);
}
@@ -58,6 +61,7 @@ async function waitForStableTranscriptGeometry(
const startedAt = performance.now();
let previous = null;
let stableFrames = 0;
let lastSample = null;
const sample = () => {
const element = document.querySelector(".transcript");
if (element instanceof HTMLElement) {
@@ -67,6 +71,7 @@ async function waitForStableTranscriptGeometry(
top: element.scrollTop,
clientHeight: element.clientHeight,
};
lastSample = { ...current, distance: current.height - current.top - current.clientHeight, stableFrames };
const unchanged = previous != null
&& current.mode === previous.mode
&& Math.abs(current.height - previous.height) <= 0.5
@@ -85,7 +90,7 @@ async function waitForStableTranscriptGeometry(
stableFrames = 0;
}
if (performance.now() - startedAt >= timeout) {
reject(new Error(`transcript geometry did not stay stable for ${frames} frames`));
reject(new Error(`transcript geometry did not stay stable for ${frames} frames: ${JSON.stringify(lastSample)}`));
return;
}
requestAnimationFrame(sample);
@@ -94,6 +99,170 @@ async function waitForStableTranscriptGeometry(
}), { timeout, frames, requireTail });
}
async function openGeometryContractFixture(page) {
await page.click('.project-tree__topic-main:has-text("bench:geometry-229")');
await page.waitForFunction(
() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete."),
undefined,
{ timeout: 30_000 },
);
await waitForStableTranscriptGeometry(page, { timeout: 30_000, requireTail: true });
return page.locator(".transcript");
}
async function runGeometryContractTraversal(page, label) {
const transcript = page.locator(".transcript");
const fixtureShape = await transcript.evaluate((element) => ({
totalRows: Number.parseInt(element.dataset.transcriptRowCount ?? "0", 10),
clientHeight: element.clientHeight,
}));
assert(
fixtureShape.totalRows >= 220 && fixtureShape.totalRows <= 240,
`${label}: sanitized fixture stays near 229 rows (${fixtureShape.totalRows})`,
);
await page.evaluate(() => {
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => {
window.__geometryContractProbe?.writes.push(write);
};
});
await transcript.evaluate(() => {
const compact = {};
window.__geometryContractProbe = { active: true, frames: [], compact, allRows: {}, writes: [] };
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => window.__geometryContractProbe?.writes.push(write);
const sample = () => {
const probe = window.__geometryContractProbe;
const element = document.querySelector(".transcript");
if (!probe?.active || !(element instanceof HTMLElement)) return;
const viewport = element.getBoundingClientRect();
const mounted = [...element.querySelectorAll(".transcript__row")];
const visible = mounted
.filter((row) => {
const rect = row.getBoundingClientRect();
return rect.bottom > viewport.top && rect.top < viewport.bottom;
})
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
const first = visible[0];
const firstVisibleIndex = first instanceof HTMLElement
? Number.parseInt(first.dataset.logicalIndex ?? first.dataset.index ?? "", 10)
: Number.NaN;
probe.frames.push({
top: element.scrollTop,
height: element.scrollHeight,
estimatedTotal: element.dataset.transcriptEstimatedTotal,
firstVisibleIndex: Number.isFinite(firstVisibleIndex) ? firstVisibleIndex : null,
firstVisibleTop: first instanceof HTMLElement ? first.getBoundingClientRect().top - viewport.top : null,
occupied: visible.length > 0,
visibleRows: visible.slice(0, 8).map((row) => ({
index: row.dataset.logicalIndex ?? row.dataset.index,
key: row.dataset.rowKey,
kind: row.dataset.rowKind,
variant: row.dataset.transcriptLayoutVariant,
height: row.getBoundingClientRect().height,
top: row.getBoundingClientRect().top - viewport.top,
})),
});
for (const row of mounted) {
if (!(row instanceof HTMLElement)) continue;
const variant = row.dataset.transcriptLayoutVariant ?? "";
const index = Number.parseInt(row.dataset.logicalIndex ?? row.dataset.index ?? "", 10);
const estimated = Number.parseFloat(row.dataset.estimatedSize ?? row.dataset.staticEstimate ?? "");
const measured = Number.parseFloat(row.dataset.knownSize ?? "") || row.getBoundingClientRect().height;
if (!Number.isFinite(index) || !Number.isFinite(estimated) || !Number.isFinite(measured)) continue;
const key = `${index}:${variant}`;
const error = Math.abs(measured - estimated);
const delta = measured - estimated;
const rowSample = {
index,
key: row.dataset.rowKey,
preview: row.textContent?.slice(0, 48),
kind: row.dataset.rowKind,
variant,
estimated,
measured,
error,
delta,
};
const previousAll = probe.allRows[key];
if (!previousAll || error > previousAll.error) probe.allRows[key] = rowSample;
if (variant === "static" || variant === "text-flow" || variant.endsWith("-expanded")) continue;
const previous = compact[key];
if (!previous || error > previous.error) compact[key] = rowSample;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
await moveToOuterReaderGutter(page, transcript);
let reachedTop = false;
for (let attempt = 0; attempt < 240 && !reachedTop; attempt += 1) {
// Virtuoso recycles the row under a fixed pointer coordinate. Re-resolve
// the outer reader target so every synthetic wheel is delivered through
// the same capture path as a real continuous gesture.
await moveToOuterReaderGutter(page, transcript, false);
await page.mouse.wheel(0, -80);
await page.waitForTimeout(60);
reachedTop = await transcript.evaluate((element) => element.scrollTop <= 1);
}
assert(reachedTop, `${label}: first upward traversal reaches the physical top`);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const probe = await transcript.evaluate(() => {
const current = window.__geometryContractProbe ?? { frames: [], compact: {}, writes: [] };
current.active = false;
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
window.__geometryContractProbe = undefined;
return current;
});
const occupied = probe.frames.every((frame) => frame.occupied);
let maxReversePx = 0;
let maxReverseRows = 0;
let worstReversePair = null;
let reverseRun = 0;
let maxReverseRun = 0;
let maxHeightDrop = 0;
for (let index = 1; index < probe.frames.length; index += 1) {
const previous = probe.frames[index - 1];
const current = probe.frames[index];
maxReversePx = Math.max(maxReversePx, current.top - previous.top);
maxHeightDrop = Math.max(maxHeightDrop, previous.height - current.height);
if (previous.firstVisibleIndex != null && current.firstVisibleIndex != null) {
const reverseRows = current.firstVisibleIndex - previous.firstVisibleIndex;
if (reverseRows > maxReverseRows) {
maxReverseRows = reverseRows;
worstReversePair = { previous, current };
}
const visualReverse = current.firstVisibleIndex > previous.firstVisibleIndex
|| (current.firstVisibleIndex === previous.firstVisibleIndex
&& previous.firstVisibleTop != null
&& current.firstVisibleTop != null
&& current.firstVisibleTop < previous.firstVisibleTop - 2);
reverseRun = visualReverse ? reverseRun + 1 : 0;
maxReverseRun = Math.max(maxReverseRun, reverseRun);
}
}
const compactRows = Object.values(probe.compact);
const reasoningRows = compactRows.filter((row) => row.variant === "reasoning-summary");
const reasoningError = Math.max(0, ...reasoningRows.map((row) => row.error));
const otherCompactError = Math.max(0, ...compactRows.filter((row) => row.variant !== "reasoning-summary").map((row) => row.error));
const forbiddenWrites = probe.writes.filter((write) => write.owner === "recovery" || write.owner === "anchor-compensation");
const largestOverestimates = Object.values(probe.allRows)
.filter((row) => row.delta < 0)
.sort((left, right) => left.delta - right.delta)
.slice(0, 8);
const largestUnderestimates = Object.values(probe.allRows)
.filter((row) => row.delta > 0)
.sort((left, right) => right.delta - left.delta)
.slice(0, 8);
assert(occupied, `${label}: every sampled frame keeps a mounted row in the viewport`);
assert(maxReverseRows <= 1, `${label}: first visible row never flashes downward by more than one row (${maxReverseRows}; ${JSON.stringify(worstReversePair)}; reasoning error ${reasoningError.toFixed(2)}; compact error ${otherCompactError.toFixed(2)}; height drop ${maxHeightDrop.toFixed(2)}; over ${JSON.stringify(largestOverestimates)}; under ${JSON.stringify(largestUnderestimates)}; writes ${JSON.stringify(probe.writes.slice(-8))})`);
assert(maxReverseRun <= 1, `${label}: no visible reverse displacement persists beyond one frame (${maxReverseRun}; physical anchor adjustment ${maxReversePx.toFixed(2)}px)`);
assert(maxHeightDrop < fixtureShape.clientHeight / 2, `${label}: list height never contracts by half a viewport (${maxHeightDrop.toFixed(2)}px)`);
assert(reasoningRows.length === 31, `${label}: traversal measures all 31 folded reasoning rows (${reasoningRows.length})`);
assert(reasoningError <= 2, `${label}: folded reasoning estimate error stays within 2px (${reasoningError.toFixed(2)}px)`);
assert(otherCompactError <= 8, `${label}: other compact estimate errors stay within 8px (${otherCompactError.toFixed(2)}px)`);
assert(forbiddenWrites.length === 0, `${label}: normal traversal emits zero recovery/anchor-compensation writes (${forbiddenWrites.length})`);
return { reasoningError, otherCompactError, totalRows: fixtureShape.totalRows };
}
async function waitForServer() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
@@ -110,16 +279,22 @@ async function waitForServer() {
throw new Error("transcript scroll preview did not become ready");
}
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
const packageManager = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const preview = spawn(packageManager, ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
cwd: frontendDir,
stdio: "ignore",
shell: process.platform === "win32",
});
let browser;
try {
await waitForServer();
browser = await chromium.launch({
headless: true,
// Chromium's headless compositor reserves the native scrollbar gutter on
// Linux but does not expose its thumb to pointer input. CI runs this replay
// in Xvfb so the real browser-owned thumb remains draggable; other hosts
// keep the deterministic headless path and their platform-specific gates.
headless: !nativeThumbReplay,
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
});
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
@@ -145,7 +320,7 @@ try {
max: element.scrollHeight - element.clientHeight,
}));
assert(hydrationStart.max > 0, `async hydration fixture is scrollable (${hydrationStart.max}px)`);
await page.mouse.move(hydrationBox.x + hydrationBox.width / 2, hydrationBox.y + hydrationBox.height / 2);
await moveToOuterReaderGutter(page, hydrationTranscript);
await page.mouse.wheel(0, hydrationStart.top < hydrationStart.max / 2 ? 360 : -360);
await page.waitForFunction(
(startTop) => {
@@ -209,12 +384,24 @@ try {
requestAnimationFrame(settle);
}));
await moveToOuterReaderGutter(page, hydrationTranscript);
for (let attempt = 0; attempt < 32; attempt += 1) {
// Full Markdown hydration can legitimately expand this synthetic document
// to ~30k CSS px. Keep driving the same outer-reader gesture until the
// physical tail is reached instead of assuming the old underestimated tree.
let previousDownwardTop = -1;
for (let attempt = 0; attempt < 128; attempt += 1) {
await moveToOuterReaderGutter(page, hydrationTranscript, false);
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);
const position = await hydrationTranscript.evaluate((element) => ({
top: element.scrollTop,
atBottom: element.scrollHeight - element.scrollTop - element.clientHeight <= 1,
}));
if (position.atBottom) break;
// A hydration resize intentionally guards the reader's previous extent
// for one intent burst. Start a fresh burst after the 180ms idle lease
// when that boundary is reached, matching a real user's next wheel turn.
const stalled = Math.abs(position.top - previousDownwardTop) <= 1;
previousDownwardTop = position.top;
await page.waitForTimeout(stalled ? 220 : 16);
}
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
@@ -242,16 +429,26 @@ try {
undefined,
{ timeout: 30_000 },
);
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
return element instanceof HTMLElement
&& element.dataset.scrollMode === "tail-follow"
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
});
// Tail ownership uses the shared 4px native-bottom threshold because Linux
// and WebView2 can quantize a fractional layout to different integer scroll
// extents. Require eight stable frames inside that product threshold instead
// of accepting one transient exact-bottom sample.
await waitForStableTranscriptGeometry(page, { timeout: 30_000, requireTail: true });
assert(true, "rapid A→B→A switching leaves the reported long-turn session at its physical bottom");
await openGeometryContractFixture(page);
await runGeometryContractTraversal(page, "DPR 1 first visit");
await page.click('.project-tree__topic-main:has-text("bench:small-6t")');
await page.waitForFunction(
() => document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:small-6t"),
undefined,
{ timeout: 30_000 },
);
await openGeometryContractFixture(page);
await runGeometryContractTraversal(page, "DPR 1 A→B→A revisit");
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
await page.waitForFunction(() => document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t"));
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go"), undefined, { timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector(".transcript-navigation-overlay"), undefined, { timeout: 30_000 });
const markdownVisibility = await page.evaluate(() => {
const row = document.querySelector(".transcript__row");
if (!(row instanceof HTMLElement)) return { inside: null, outside: null };
@@ -294,7 +491,10 @@ try {
const rail = document.querySelector(".jump-scroll");
if (!(rail instanceof HTMLElement)) return null;
const railRect = rail.getBoundingClientRect();
const marker = [...rail.querySelectorAll(".jump-item")].find((item) => {
// This assertion covers clearing a stale selection before an immediate,
// already-mounted indexed jump. Unloaded markers intentionally use the
// masked history transaction and are covered by the 434→847→994 replay.
const marker = [...rail.querySelectorAll('.jump-item[data-loaded="true"]')].find((item) => {
const rect = item.getBoundingClientRect();
const middle = rect.top + rect.height / 2;
return middle >= railRect.top && middle <= railRect.bottom;
@@ -303,7 +503,7 @@ try {
const rect = marker.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
});
assert(questionTargetPoint != null, "question navigator exposes an earlier visible marker");
assert(questionTargetPoint != null, "question navigator exposes an earlier loaded marker");
const staleSelectionMode = await page.evaluate(() => {
const target = document.querySelector("[data-transcript-selectable]");
const transcript = document.querySelector(".transcript");
@@ -711,7 +911,31 @@ try {
element.dispatchEvent(new Event("scroll"));
});
await page.waitForFunction(() => document.querySelector(".transcript__row"));
const nativeThumbProbe = await transcript.evaluate((element) => {
let nativeThumbProbe = await transcript.evaluate(async (element) => {
// The scroll-to-top event can leave a recycled Virtuoso node mounted or a
// delayed layout owner can move the viewport again for a few frames.
// Starting the drag in either state can hit the scrollbar track instead of
// the top-positioned thumb, recycling the marked node before the geometry
// freeze begins. Require both the physical top and the logical row identity
// to settle before marking the probe.
let stableFrames = 0;
let previousIdentity = "";
for (let frame = 0; frame < 120 && stableFrames < 4; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
if (element.scrollTop > 1) {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
stableFrames = 0;
previousIdentity = "";
continue;
}
const candidate = element.querySelector(".transcript__row");
const identity = candidate instanceof HTMLElement
? `${candidate.dataset.rowKey ?? ""}:${candidate.dataset.knownSize ?? ""}`
: "";
stableFrames = identity && identity === previousIdentity ? stableFrames + 1 : 0;
previousIdentity = identity;
}
const rect = element.getBoundingClientRect();
const scaleX = rect.width / element.offsetWidth;
const contentRight = rect.left + (element.clientLeft + element.clientWidth) * scaleX;
@@ -721,27 +945,179 @@ try {
return {
x: Math.min(rect.right - 1, contentRight + Math.max(1, (rect.right - contentRight) / 2)),
y: rect.top + 5,
// Browser themes clamp the held thumb only after the pointer crosses
// the track end, so the target deliberately overshoots the visible
// gutter where a native thumb is available.
bottomY: Math.min(window.innerHeight - 1, rect.bottom + Math.max(24, rect.height * 0.1)),
scrollTop: element.scrollTop,
rowKey: row.dataset.rowKey ?? "",
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
gutter: rect.right - contentRight,
scrollHeight: element.scrollHeight,
};
});
if (process.platform === "darwin" && (!nativeThumbProbe || nativeThumbProbe.gutter <= 1)) {
// macOS Chromium inherits system overlay scrollbars, so there is no
// pointer-addressable native gutter. Linux/Windows CI still exercises the
// complete thumb ownership and measurement-freeze path below.
process.stdout.write(" SKIP native thumb drag (host uses overlay scrollbars)\n");
const nativeThumbDragSupported = process.platform !== "win32" || process.env.REASONIX_TRANSCRIPT_NATIVE_THUMB === "1";
if (!nativeThumbDragSupported) {
// Playwright's headless Chromium on Windows exposes the reserved gutter
// width but does not expose a pointer-draggable native thumb. The actual
// WebView2 path is covered by the Windows native smoke job; the geometry
// lock itself is covered by transcript-native-scrollbar.test.ts.
process.stdout.write(" SKIP native thumb drag (headless Windows Chromium has no draggable native track)\n");
} else if (process.platform === "darwin") {
// Headless macOS Chromium does not expose a deterministic pointer-draggable
// thumb: hosts with overlay scrollbars have no gutter, while hosts with a
// reserved gutter accept pointer-down but do not reliably move the thumb.
// Linux CI exercises the full drag/freeze path; Windows WebView2 coverage
// is provided by the native smoke job.
process.stdout.write(" SKIP native thumb drag (headless macOS Chromium has no deterministic native track)\n");
} else {
assert(nativeThumbProbe && nativeThumbProbe.gutter > 1, `workbench exposes a native scrollbar gutter (${nativeThumbProbe?.gutter ?? 0}px)`);
const trackTop = nativeThumbProbe.y - 5;
const coarseThumbCandidateOffsets = [4, 8, 12, 16, 20, 24, 28, 32];
// Native scrollbar themes may expose a very narrow draggable hitbox
// between a top button and the track. Keep the fast coarse probes first,
// then inspect every physical pixel near the track start so a theme
// boundary cannot be misclassified as host input being unavailable.
const thumbCandidateOffsets = [
...coarseThumbCandidateOffsets,
...Array.from({ length: 64 }, (_, index) => index + 1)
.filter((offset) => !coarseThumbCandidateOffsets.includes(offset)),
];
const thumbCandidateMotions = [];
let nativeThumbY = null;
let nativeThumbDragY = null;
for (const offset of thumbCandidateOffsets) {
await transcript.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await page.waitForFunction(() => (document.querySelector(".transcript")?.scrollTop ?? Number.POSITIVE_INFINITY) <= 1);
const candidateY = trackTop + offset;
await page.mouse.move(nativeThumbProbe.x, candidateY);
await page.mouse.down();
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
await page.mouse.move(nativeThumbProbe.x, candidateY + 24);
await page.waitForTimeout(24);
const midpoint = await transcript.evaluate((element) => ({
scrollTop: element.scrollTop,
clientHeight: element.clientHeight,
}));
await page.mouse.move(nativeThumbProbe.x, candidateY + 48);
await page.waitForTimeout(24);
const motion = await transcript.evaluate((element) => ({
scrollTop: element.scrollTop,
clientHeight: element.clientHeight,
}));
await page.mouse.move(nativeThumbProbe.x, candidateY + 96);
await page.waitForTimeout(24);
const confirmation = await transcript.evaluate((element) => ({
scrollTop: element.scrollTop,
clientHeight: element.clientHeight,
}));
thumbCandidateMotions.push({
offset,
midpoint: midpoint.scrollTop,
scrollTop: motion.scrollTop,
confirmation: confirmation.scrollTop,
});
// A real thumb follows every pointer increment. A held track/button can
// auto-repeat across several pages and can satisfy the first two distance
// checks before stopping. Require a third, farther move to prove that the
// browser-owned control keeps following the pointer transaction.
if (motion.scrollTop > motion.clientHeight * 2.5
&& motion.scrollTop - midpoint.scrollTop > motion.clientHeight
&& confirmation.scrollTop - motion.scrollTop > confirmation.clientHeight * 2) {
nativeThumbY = candidateY;
nativeThumbDragY = candidateY + 96;
break;
}
await page.mouse.up();
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag !== "true");
}
if (nativeThumbY === null) {
process.stdout.write(` SKIP native thumb drag (headed Linux Chromium host-input-unavailable; ${JSON.stringify(thumbCandidateMotions)})\n`);
} else {
// Continue the same pointer transaction that proved native input was
// delivered. Releasing, resetting, and reusing the old Y coordinate does
// not prove the browser theme exposed the thumb at that coordinate again.
// Finish the browser-owned drag before asynchronous DOM probes: Chromium
// can stop delivering native-track movement after a held transaction is
// interrupted by fixture mutations even though pointer ownership remains.
const nativeTailSamples = [];
const nativeDragStepCount = 24;
for (let step = 1; step <= nativeDragStepCount; step += 1) {
const y = nativeThumbDragY + ((nativeThumbProbe.bottomY - nativeThumbDragY) * step) / nativeDragStepCount;
await page.mouse.move(nativeThumbProbe.x, y);
// Give Chromium one frame to apply each browser-owned thumb movement;
// coalescing one large synthetic move can skip the native track's
// intermediate geometry while Virtuoso swaps its mounted range.
await page.waitForTimeout(24);
const sample = await transcript.evaluate((element) => ({
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
bottomDistance: element.scrollHeight - element.scrollTop - element.clientHeight,
mode: element.dataset.scrollMode,
}));
nativeTailSamples.push({ y: Math.round(y), ...sample });
if (sample.bottomDistance <= 4) break;
}
const nativeTailTerminal = nativeTailSamples.at(-1);
assert(nativeTailSamples.every((sample) => sample.mode === "native-thumb"), `native thumb retains ownership through the delivered drag (${JSON.stringify(nativeTailSamples)})`);
assert(nativeTailTerminal?.bottomDistance <= 4, `native thumb drag reaches the physical bottom (${JSON.stringify(nativeTailSamples)})`);
nativeThumbProbe = await transcript.evaluate((element, input) => {
element.querySelectorAll('[data-native-scrollbar-probe="true"]').forEach((node) => {
if (node instanceof HTMLElement) delete node.dataset.nativeScrollbarProbe;
});
const viewport = element.getBoundingClientRect();
const row = [...element.querySelectorAll(".transcript__row")]
.find((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.bottom > viewport.top && rect.top < viewport.bottom;
});
if (!(row instanceof HTMLElement)) return null;
row.dataset.nativeScrollbarProbe = "true";
return {
...input.probe,
y: input.y,
scrollTop: element.scrollTop,
clientHeight: element.clientHeight,
rowKey: row.dataset.rowKey ?? "",
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
scrollHeight: element.scrollHeight,
};
}, { probe: nativeThumbProbe, y: nativeThumbY });
assert(nativeThumbProbe, "native scrollbar probe remains available after draggable-thumb discovery");
assert(true, `native scrollbar exposes a pointer-draggable thumb (${Math.round(nativeThumbY - trackTop)}px from track start)`);
assert(nativeThumbProbe.scrollTop > nativeThumbProbe.clientHeight * 2.5, `native scrollbar discovery delivers forward physical progress (${nativeThumbProbe.scrollTop}px)`);
assert(nativeThumbProbe.knownSize > 0, `native scrollbar probe starts from a measured row (${nativeThumbProbe.knownSize}px)`);
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.y);
await page.mouse.down();
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
await page.waitForFunction(
(knownSize) => document.querySelector('[data-native-scrollbar-probe="true"]')?.style.height === `${knownSize}px`,
nativeThumbProbe.knownSize,
);
const nativeDragStart = await transcript.evaluate((element) => {
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
return {
scrollTop: element.scrollTop,
rowKey: row instanceof HTMLElement ? row.dataset.rowKey ?? "" : "",
};
});
assert(Math.abs(nativeDragStart.scrollTop - nativeThumbProbe.scrollTop) <= 1, `native thumb transaction keeps its delivered position (${nativeThumbProbe.scrollTop} → ${nativeDragStart.scrollTop}px)`);
assert(nativeDragStart.rowKey === nativeThumbProbe.rowKey, `native thumb press keeps the probed logical row (${nativeDragStart.rowKey || "missing"})`);
await page.waitForFunction(({ knownSize, rowKey }) => {
const row = document.querySelector('[data-native-scrollbar-probe="true"]');
return row instanceof HTMLElement
&& row.dataset.rowKey === rowKey
&& row.style.height === `${knownSize}px`;
}, { knownSize: nativeThumbProbe.knownSize, rowKey: nativeThumbProbe.rowKey });
const nativeDragBaseline = await transcript.evaluate((element) => {
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
return {
knownSize: row instanceof HTMLElement ? Number.parseFloat(row.dataset.knownSize || "0") : 0,
fixedHeight: row instanceof HTMLElement ? row.style.height : "",
rowHeight: row instanceof HTMLElement ? row.getBoundingClientRect().height : 0,
listHeight: element.querySelector('[data-testid="virtuoso-item-list"]')?.getBoundingClientRect().height ?? 0,
scrollHeight: element.scrollHeight,
};
});
assert(nativeDragBaseline?.knownSize > 0, `native thumb drag starts from a measured logical row (${nativeDragBaseline?.knownSize ?? 0}px)`);
assert(nativeDragBaseline.fixedHeight === `${nativeDragBaseline.knownSize}px`, `native thumb drag fixes mounted row layout (${nativeDragBaseline.fixedHeight})`);
await transcript.evaluate((element) => {
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
const content = row?.firstElementChild;
@@ -758,14 +1134,9 @@ try {
scrollHeight: element.scrollHeight,
};
});
assert(duringNativeThumbDrag.knownSize === nativeThumbProbe.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
assert(duringNativeThumbDrag.fixedHeight === `${nativeThumbProbe.knownSize}px`, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeThumbProbe.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeThumbProbe.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.bottomY, { steps: 8 });
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
return element && element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
});
assert(duringNativeThumbDrag.knownSize === nativeDragBaseline.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
assert(duringNativeThumbDrag.fixedHeight === nativeDragBaseline.fixedHeight, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeDragBaseline.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeDragBaseline.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
await page.mouse.up();
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag !== "true");
await transcript.evaluate((element) => {
@@ -777,7 +1148,7 @@ try {
const element = document.querySelector(".transcript");
return element
&& element.dataset.scrollMode === "tail-follow"
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 4;
});
assert(true, "native thumb release keeps the remeasured transcript at the physical bottom");
const idleTail = await transcript.evaluate((element) => new Promise((resolve) => {
@@ -815,6 +1186,7 @@ try {
assert(idleTail.geometryStable, "an idle expanded tail reaches stable geometry within the window");
assert(idleTail.stableWrites.length === 0, `a stable idle tail emits no corrective scroll writes (${idleTail.stableWrites.length} of ${idleTail.writes.length}) ${JSON.stringify(idleTail.stableWrites)}`);
assert(idleTailRange <= 1, `an idle expanded tail keeps stable native geometry (${idleTailRange}px)`);
}
}
// Explicit bottom owns the tail. Subsequent async growth must use Virtuoso's
@@ -936,7 +1308,15 @@ try {
element.dataset.scrollMode === "tail-follow"
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1);
}
assert(reachedBottom, "repeated downward wheels reach the physical bottom through measurement churn (#8657)");
const reachState = reachedBottom ? null : await transcript.evaluate((element) => ({
mode: element.dataset.scrollMode,
distance: element.scrollHeight - element.scrollTop - element.clientHeight,
top: element.scrollTop,
height: element.scrollHeight,
clientHeight: element.clientHeight,
writes: window.__reachBottomProbe.writes.slice(-8),
}));
assert(reachedBottom, `repeated downward wheels reach the physical bottom through measurement churn (#8657)${reachState ? `: ${JSON.stringify(reachState)}` : ""}`);
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "tail-follow", undefined, { timeout: 5_000 });
const reachProbe = await transcript.evaluate(() => {
window.__reachBottomProbe.done = true;
+148 -8
View File
@@ -136,9 +136,136 @@ async function waitForServer() {
throw new Error("transcript browser preview did not become ready");
}
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
async function runSelectionTableRepaintGeometry(page) {
await page.click('.project-tree__topic-main:has-text("bench:selection-table")');
await page.waitForFunction(() => (
document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:selection-table")
&& [...document.querySelectorAll("strong")].some((element) => element.textContent?.includes("SELECTION REPAINT TARGET"))
), undefined, { timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector(".transcript-navigation-overlay"), undefined, { timeout: 30_000 });
const target = page.locator("strong", { hasText: "SELECTION REPAINT TARGET" });
await target.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const targetBox = await target.boundingBox();
assert(targetBox != null, "selection-table exposes the strong-text multi-click target");
const initial = await page.evaluate(() => {
const targetElement = [...document.querySelectorAll("strong")].find((element) => element.textContent?.includes("SELECTION REPAINT TARGET"));
const transcript = document.querySelector(".transcript");
const table = targetElement?.closest("table");
const row = targetElement?.closest("tr");
const host = document.querySelector('.transcript-selection-action[data-surface="transcript"]');
if (!targetElement || !transcript || !table || !row || !host) return null;
const abort = new AbortController();
const rect = (element) => {
const value = element.getBoundingClientRect();
return { top: value.top, left: value.left, right: value.right, bottom: value.bottom };
};
const capture = (event) => {
const currentHost = document.querySelector('.transcript-selection-action[data-surface="transcript"]');
const selection = document.getSelection();
window.__selectionTableProbe.samples.push({
event,
scrollTop: transcript.scrollTop,
scrollHeight: transcript.scrollHeight,
clientHeight: transcript.clientHeight,
table: rect(table),
row: rect(row),
target: rect(targetElement),
hostCount: document.querySelectorAll('.transcript-selection-action[data-surface="transcript"]').length,
hostStable: currentHost === host,
hostState: currentHost?.getAttribute("data-state") ?? null,
selectionRects: selection?.rangeCount
? [...selection.getRangeAt(selection.rangeCount - 1).getClientRects()].map((value) => ({
top: value.top,
left: value.left,
right: value.right,
bottom: value.bottom,
}))
: [],
});
};
window.__selectionTableProbe = { host, samples: [], capture, abort };
document.addEventListener("pointerdown", () => capture("pointerdown"), { capture: true, signal: abort.signal });
document.addEventListener("pointerup", () => capture("pointerup"), { capture: true, signal: abort.signal });
document.addEventListener("selectionchange", () => capture("selectionchange"), { signal: abort.signal });
capture("initial");
return window.__selectionTableProbe.samples[0];
});
assert(initial != null, "selection-table geometry probe attaches to the settled transcript");
assert(initial.scrollTop > 0, `selection-table target exercises a non-top transcript viewport (${initial.scrollTop})`);
assert(initial.hostCount === 1 && initial.hostStable, "selection-table starts with one stable transcript action host");
const clickIntervals = [400, 320, 180, 0];
const clickPoint = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
await page.mouse.move(clickPoint.x, clickPoint.y);
for (let index = 0; index < clickIntervals.length; index += 1) {
await page.mouse.down({ button: "left", clickCount: index + 1 });
await page.waitForTimeout(30);
await page.mouse.up({ button: "left", clickCount: index + 1 });
await page.evaluate(async (click) => {
await new Promise((resolve) => requestAnimationFrame(resolve));
window.__selectionTableProbe.capture(`click-${click}-raf-1`);
await new Promise((resolve) => requestAnimationFrame(resolve));
window.__selectionTableProbe.capture(`click-${click}-raf-2`);
}, index + 1);
if (clickIntervals[index] > 0) await page.waitForTimeout(clickIntervals[index]);
}
await page.keyboard.press("Escape");
await page.evaluate(async () => {
document.getSelection()?.removeAllRanges();
await new Promise((resolve) => requestAnimationFrame(resolve));
await new Promise((resolve) => requestAnimationFrame(resolve));
window.__selectionTableProbe.capture("settled");
});
const result = await page.evaluate(() => {
const probe = window.__selectionTableProbe;
probe.abort.abort();
const samples = probe.samples;
const baseline = samples[0];
const maxDelta = (field) => Math.max(...samples.map((sample) => Math.max(
Math.abs(sample[field].top - baseline[field].top),
Math.abs(sample[field].left - baseline[field].left),
)));
const settled = samples.at(-1);
delete window.__selectionTableProbe;
return {
samples: samples.length,
maxTableDelta: maxDelta("table"),
maxRowDelta: maxDelta("row"),
maxTargetDelta: maxDelta("target"),
scrollStable: samples.every((sample) => (
sample.scrollTop === baseline.scrollTop
&& sample.scrollHeight === baseline.scrollHeight
&& sample.clientHeight === baseline.clientHeight
)),
hostStable: samples.every((sample) => sample.hostCount === 1 && sample.hostStable),
observedOpen: samples.some((sample) => sample.hostState === "open"),
settledState: settled.hostState,
settledButtonDisabled: document.querySelector('.transcript-selection-action[data-surface="transcript"] button')?.disabled,
settledButtonTabIndex: document.querySelector('.transcript-selection-action[data-surface="transcript"] button')?.tabIndex,
};
});
assert(result.samples >= 13, `selection-table records pointer, selection, and frame geometry (${result.samples} samples)`);
assert(result.hostStable, "four native multi-clicks retain one transcript action host identity");
assert(result.observedOpen, "native multi-click selection opens the transcript action host");
assert(result.scrollStable, "native multi-click selection preserves scrollTop/scrollHeight/clientHeight");
assert(result.maxTableDelta <= 0.5, `table top/left stay within 0.5px (${result.maxTableDelta})`);
assert(result.maxRowDelta <= 0.5, `table row top/left stay within 0.5px (${result.maxRowDelta})`);
assert(result.maxTargetDelta <= 0.5, `strong target top/left stay within 0.5px (${result.maxTargetDelta})`);
assert(
result.settledState === "closed" && result.settledButtonDisabled && result.settledButtonTabIndex === -1,
"settled action host is closed, disabled, and outside the tab order",
);
}
const packageManager = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const preview = spawn(packageManager, ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
cwd: frontendDir,
stdio: "ignore",
shell: process.platform === "win32",
});
let browser;
@@ -160,20 +287,33 @@ try {
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
await runSelectionTableRepaintGeometry(page);
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
await page.waitForFunction(() => (
document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t")
&& document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go")
), undefined, { timeout: 30_000 });
await page.waitForFunction(
() => !document.querySelector(".transcript-navigation-overlay"),
undefined,
{ timeout: 30_000 },
);
await page.waitForTimeout(300);
for (let pageIndex = 0; pageIndex < 8; pageIndex += 1) {
await page.evaluate(() => {
// Preload the selection fixture with real upward wheel intent. Directly
// assigning scrollTop used to make Virtuoso's startReached callback page in
// the background, but viewport paging now requires one permit per page.
for (let pageIndex = 0; pageIndex < 32; pageIndex += 1) {
const before = Number(await page.locator(".transcript").getAttribute("data-transcript-row-count") ?? 0);
const box = await page.locator(".transcript").boundingBox();
if (!box) break;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -100_000);
const loaded = await page.waitForFunction((previous) => {
const transcript = document.querySelector(".transcript");
if (transcript) transcript.scrollTop = 0;
});
await page.waitForTimeout(100);
if (!(await clickIfPresent(page, ".transcript__older"))) break;
await page.waitForTimeout(350);
const current = Number(transcript?.getAttribute("data-transcript-row-count") ?? 0);
return current > previous;
}, before, { timeout: 2_000 }).then(() => true, () => false);
if (!loaded) break;
}
await clickIfPresent(page, ".transcript__jump-bottom");
try {
+5
View File
@@ -23,5 +23,10 @@
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
<!-- Wails v2.13 dev spinner anchor: the dev runtime mounts its loading
overlay here; without the element the overlay anchors on null and its
Svelte internals crash ("Cannot read properties of null (reading
'nodes')"), which the app's error screen then escalates. -->
<div id="wails-spinner" aria-hidden="true"></div>
</body>
</html>
+9 -6
View File
@@ -3,13 +3,14 @@
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "[email protected]",
"scripts": {
"dev": "vite",
"build": "pnpm lint:hooks && pnpm check:waapi && pnpm check:scroll-writer && node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
"build": "pnpm lint:hooks && pnpm check:waapi && pnpm check:scroll-writer && node scripts/check-css-syntax.mjs src/styles.css src/components/RemoteConnectWizard.css src/components/TranscriptSelectionMenu.css && node scripts/check-z-index-tokens.mjs src/styles.css src/components/RemoteConnectWizard.css && node scripts/check-theme-token-contract.mjs && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
"check:bundle": "node scripts/check-bundle-budget.mjs",
"check:scroll-writer": "node scripts/check-single-scroll-writer.mjs",
"preview": "vite preview",
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"check:css": "node scripts/check-css-syntax.mjs src/styles.css src/components/RemoteConnectWizard.css && node scripts/check-z-index-tokens.mjs src/styles.css src/components/RemoteConnectWizard.css && node scripts/check-theme-token-contract.mjs",
"check:waapi": "node scripts/check-waapi-contract.mjs",
"lint:hooks": "eslint \"src/**/*.{ts,tsx}\"",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
@@ -22,13 +23,15 @@
"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-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 && 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:theme-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/theme-surface-contract.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-geometry-environment.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-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-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-selection-rendering.test.ts && 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": "node bench/transcript-selection.mjs && node bench/transcript-scroll-stability.mjs",
"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:diagnostics && 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: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 && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/remote-connect-wizard.test.tsx && tsx src/__tests__/add-project-entries.test.ts && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx && tsx src/__tests__/remote-running-reconcile.test.ts && tsx src/__tests__/remote-project-tree.test.tsx && tsx src/__tests__/remote-session-cache.test.ts && tsx src/__tests__/remote-tab-opened.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
"test:bench": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/run.mjs",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
@@ -81,9 +81,64 @@ console.log("\nbundle budgets");
// 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;
// build from 427.2 to 427.7 KiB and test from 428.6 to 429.1 KiB. The unified
// state-aware geometry contract, session diagnostics counters, and guarded
// native-scroll probes add 2.4 KiB gzip to the initial path. The current
// main-v2 merge adds another 0.3 KiB of deterministic shared startup code.
// Keep the increase explicit and bounded instead of hiding it in a broad
// percentage ratchet.
// The retained-transcript surface adds a small, bounded navigation owner to
// the startup path (overlay state + stale-completion guard). Keep the increase
// explicit and narrow; the measured build is 431.1 KiB gzip.
// The web-search tool card now resolves the same display projection lazily so
// its filtered count matches the assistant Sources panel. The measured build
// is 431.509 KiB gzip; keep 0.1 KiB of explicit headroom for hash/toolchain
// drift instead of relying on a rounded equality.
// Remote onboarding [0.5/3] adds project-group and credential-chain wiring on
// top of the lazy wizard. Exact-turn routing, the extracted event-gap
// projector, checkpoint resets, and the navigation surface transaction bring
// the current main-v2 path to 437.36 KiB gzip.
// The full remote-session surface adds the lazy transcript bridge and tab
// lifecycle on top of [0.5/3]. Keep the measured stack's narrow ratchet.
// Remote approval hardening adds authoritative composer-profile hydration,
// scoped rewind dispatch, and attachment/inbox fences to the always-mounted
// remote hook. The measured production path is 438.38 KiB gzip after keeping
// the integration modules below repolint's ownership ceilings; retain 0.12 KiB
// toolchain headroom with a bounded 1.4 KiB ratchet.
// Remote status isolation keeps the always-mounted status bar on the active
// remote transcript and routes job cancellation to that host. Parsers and
// retry policy remain lazy; the measured selector adds under 0.1 KiB gzip.
// Remote runtime parity adds scoped approvals, status-only reconciliation,
// session quality-floor routing, dropped-frame reconciliation, and remote
// runtime-command dispatch. The measured initial path is 439.60 KiB;
// retain 0.10 KiB of bounded toolchain headroom.
// Closing the remaining review gaps adds generation-fenced hydration plus
// remote-only tool payload, Todo, and terminal isolation. The measured path is
// 439.74 KiB; retain 0.06 KiB of headroom with a 0.1 KiB ratchet.
// The final remote-runtime parity pass adds remote run-strip telemetry,
// explicit session verbs, and specialized plan decisions. The measured path
// is 440.02 KiB. The current main-v2 turn-event, finish-protocol, and session
// repair runtime then moves the combined path to 445.097 KiB; retain 0.103 KiB
// of bounded build/toolchain headroom.
// Atomic remote profile changes, exact approval draining, and generation-safe
// history handoff bring the measured path to 445.228 KiB. Retain 0.072 KiB of
// headroom with the smallest existing decimal ratchet.
// Direct pending-prompt recovery and authoritative remote Goal state bring the
// measured path to 445.473 KiB. Retain 0.027 KiB of bounded headroom.
// Restored remote shells now activate their backend session immediately and
// keep disconnected state out of the mounted surface. The merged production
// path measures 445.614 KiB; retain 0.086 KiB of bounded build/toolchain
// headroom with the smallest existing decimal ratchet.
// Runtime-aware Todo presentation plus exact-tab continuation adds 0.3 KiB gzip
// to the always-mounted footer path. Keep the state/routing guard with a narrow
// ratchet rather than showing idle restored work as actively running. The
// combined path measures 445.9 KiB; retain 0.1 KiB of toolchain headroom.
// Transcript surface ownership and the token-fenced unloaded-question commit
// move the exact main-v2 baseline from 445.865 to 447.587 KiB gzip (+0.39%).
// The final 0.266 KiB retains jump ownership through paint-ready instead of
// allowing a native scrollend to release it. Keep only 0.213 KiB headroom;
// native validation hosts and test fixtures stay outside the production graph.
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 447.8 : 447.8;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
@@ -97,7 +152,12 @@ if (initialCSS.length > 0) {
// 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.
assertBudget("deferred app-shell CSS gzip", appShellCSSGzip, 114 * 1024);
// Navigation overlay styles add a bounded 0.1 KiB to the deferred shell.
// The cleaned source panel adds 0.1 KiB gzip to the deferred shell on top of
// the retained-transcript navigation allowance; keep the ratchet explicit.
// The navigation mask's stable composer footprint and remote tab/surface
// states bring the merged shell to roughly 115.7 KiB gzip.
assertBudget("deferred app-shell CSS gzip", appShellCSSGzip, 116.0 * 1024);
if (localeChunks.length !== 2) {
throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`);
}
@@ -120,8 +180,12 @@ for (const path of localeChunks) {
// 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;
// platform-dependent gate. The OpenCode one-key setup adds product-level
// connection, fallback, and legacy-state copy while removing protocol
// choices from the primary UI; keep that complete guidance with a bounded
// 0.4–0.5 KiB locale-only ratchet.
// Remote-session actions and disconnected-shell guidance add bounded copy.
const budget = name.startsWith("zh-TW-") ? 58.5 * 1024 : 57.8 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}
@@ -134,9 +198,24 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// 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;
// production and test channels both measure 2357.92 KiB after project-group
// wiring. Exact-turn routing, checkpoint resets, and failure-atomic navigation
// bring the current main-v2 path to 2379.22 KiB. The remote approval
// fences, extracted ownership modules, and remote status-bar isolation bring
// the measured initial payload to 2380.9 KiB; retain 0.1 KiB of bounded
// raw/toolchain headroom. Scoped remote approvals, status reconciliation, and
// runtime command dispatch bring the measured payload to 2382.9 KiB. The
// remaining review fences measure 2383.2 KiB; retain 0.1 KiB of headroom.
// Final remote-runtime parity measures 2384.4 KiB raw. The current main-v2
// runtime additions bring the combined path to 2404.364 KiB. The final merged
// restored-shell activation and disconnected-state revival path measures
// 2404.898 KiB; retain 0.102 KiB of bounded headroom alongside the gzip
// ratchet above.
// Runtime-aware Todo status and exact-tab continuation then add to the same
// initial path. The combined payload measures 2406.2 KiB; retain 0.1 KiB of
// raw/toolchain headroom for both owners.
// The same transcript transaction measures 2413.012 KiB raw (+0.28%) against
// the 2406.204 KiB baseline. Retain 0.188 KiB of bounded headroom.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_413.2 : 2_413.2;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
@@ -11,6 +11,8 @@ const appSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/App.tsx")
const bridgeSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/lib/bridge.ts"), "utf8");
const desktopMainSource = readFileSync(resolve(repoRoot, "desktop/main.go"), "utf8");
const transcriptScrollBenchSource = readFileSync(resolve(repoRoot, "desktop/frontend/bench/transcript-scroll-stability.mjs"), "utf8");
const transcriptSelectionBenchSource = readFileSync(resolve(repoRoot, "desktop/frontend/bench/transcript-selection.mjs"), "utf8");
const transcriptSelectionComponentSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/components/TranscriptSelectionMenu.tsx"), "utf8");
function jobBody(name, nextName) {
const match = workflow.match(new RegExp(`\\n ${name}:\\n([\\s\\S]*?)\\n ${nextName}:`));
@@ -35,12 +37,35 @@ for (const required of [
"../scripts/test-webview2-native-smoke.ps1 -SelfTest",
"Smoke-test Wails/WebView2 native startup",
"../scripts/test-webview2-native-smoke.ps1",
"Test WebView2 transcript selection compositor",
"../scripts/test-transcript-selection-webview2.ps1 -Iterations 3",
"Upload WebView2 transcript selection evidence",
]) {
if (!windowsJob.includes(required)) {
throw new Error(`motion-ci-contract: desktop-windows must include ${required}`);
}
}
for (const required of [
"bench:selection-table",
"SELECTION REPAINT TARGET",
"clickIntervals = [400, 320, 180, 0]",
"maxTargetDelta <= 0.5",
]) {
if (!transcriptSelectionBenchSource.includes(required)) {
throw new Error(`motion-ci-contract: transcript selection browser gate must retain ${required}`);
}
}
for (const required of [
'data-surface="transcript"',
"data-state={actionOverlay.phase}",
"actionOverlayStateRef.current.action",
]) {
if (!transcriptSelectionComponentSource.includes(required)) {
throw new Error(`motion-ci-contract: stable transcript selection host must retain ${required}`);
}
}
for (const [path, source] of [
["desktop/main.go", desktopMainSource],
["desktop/frontend/src/App.tsx", appSource],
@@ -50,12 +75,23 @@ for (const [path, source] of [
"REASONIX_WEBVIEW2_APPROVAL_SMOKE",
"__REASONIX_WEBVIEW2_APPROVAL_SMOKE__",
"WebView2ApprovalSmokeBridge",
"__reasonixSelectionSmoke",
"reasonix_transcript_smoke",
]) {
if (source.includes(forbidden)) {
throw new Error(`motion-ci-contract: ${path} must not embed test-only WebView2 instrumentation (${forbidden})`);
}
}
}
for (const requiredPath of [
"desktop/cmd/transcript-selection-smoke/main.go",
"desktop/transcript_selection_smoke_contract.js",
"scripts/test-transcript-selection-webview2.ps1",
]) {
if (!existsSync(resolve(repoRoot, requiredPath))) {
throw new Error(`motion-ci-contract: missing independent WebView2 selection smoke file ${requiredPath}`);
}
}
for (const retiredPath of [
"desktop/webview2_approval_smoke.go",
"desktop/frontend/src/lib/useWebView2ApprovalSmoke.ts",
@@ -105,6 +141,7 @@ for (const required of [
"markdown-pipeline.test.tsx",
"message-selection-copy.test.ts",
"transcript-selection-menu.test.tsx",
"transcript-selection-rendering.test.ts",
"transcript-store.test.ts",
"transcript-virtualization.test.tsx",
]) {
@@ -121,19 +158,23 @@ for (const required of ["transcript-selection.mjs", "transcript-scroll-stability
}
const transcriptCommand = "pnpm --dir frontend test:transcript";
const transcriptRuns = workflow.match(/pnpm --dir frontend test:transcript(?:\s|$)/g)?.length ?? 0;
if (!jobBody("desktop", "desktop-macos").includes(transcriptCommand) || transcriptRuns !== 1) {
const desktopLinuxJob = jobBody("desktop", "desktop-macos");
const transcriptRuns = desktopLinuxJob.match(/pnpm --dir frontend test:transcript(?:\s|$)/g)?.length ?? 0;
if (!desktopLinuxJob.includes(transcriptCommand) || transcriptRuns !== 1) {
throw new Error("motion-ci-contract: the Linux desktop job must run test:transcript exactly once");
}
const transcriptBrowserCommand = "pnpm --dir frontend test:transcript-browser";
const transcriptBrowserRuns = workflow.match(/pnpm --dir frontend test:transcript-browser(?:\s|$)/g)?.length ?? 0;
if (!jobBody("desktop", "desktop-macos").includes(transcriptBrowserCommand) || transcriptBrowserRuns !== 1) {
const transcriptBrowserRuns = desktopLinuxJob.match(/pnpm --dir frontend test:transcript-browser(?:\s|$)/g)?.length ?? 0;
if (!desktopLinuxJob.includes(transcriptBrowserCommand) || transcriptBrowserRuns !== 1) {
throw new Error("motion-ci-contract: the Linux desktop job must run test:transcript-browser exactly once");
}
if (!jobBody("desktop", "desktop-macos").includes("PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install")) {
if (!desktopLinuxJob.includes("PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install")) {
throw new Error("motion-ci-contract: Chromium must install into the path used by frontend browser tests");
}
if (!windowsJob.includes(transcriptBrowserCommand)) {
throw new Error("motion-ci-contract: desktop-windows must run the transcript browser replay");
}
for (const required of ["transcript-selection.mjs", "transcript-scroll-stability.mjs"]) {
if (!packageJSON.scripts?.["test:transcript-browser"]?.includes(required)) {
throw new Error(`motion-ci-contract: test:transcript-browser must include ${required}`);
@@ -5,8 +5,7 @@
*
* Only the files in ALLOWED_WRITERS may issue imperative scroll calls
* (scrollTo / scrollBy / scrollToIndex) against the transcript Virtuoso
* handle: the arbiter hook plus its extracted controllers (split out for the
* file-size budget — still one logical writer, driven only by the arbiter).
* handle: the generation-aware writer gateway owned 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
@@ -23,12 +22,9 @@ import { fileURLToPath } from "node:url";
const SOURCE_ROOT = fileURLToPath(new URL("../src", import.meta.url));
// 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.
// Every arbiter/controller command routes through this one gateway.
const ALLOWED_WRITERS = new Set([
"lib/useTranscriptScrollArbiter.ts",
"lib/transcriptTailSettle.ts",
"lib/transcriptScrollWriter.ts",
]);
// Raw `.scrollTop` writes bypass the Virtuoso handle entirely. The allowed
@@ -0,0 +1,207 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const frontendRoot = path.resolve(scriptDir, "..");
const sourceRoot = path.join(frontendRoot, "src");
const retiredTokens = new Set([
"--fg-muted",
"--bg-elev-1",
"--hover",
"--border-strong",
"--shadow",
]);
// These properties are deliberately written by React/runtime geometry code.
// Preview tokens are isolated on ThemePreviewSurface and never form part of
// the application theme contract.
const runtimeTokens = new Set([
"--composer-height",
"--invocation-color",
"--sidebar-expanded-width",
"--transcript-row-estimate",
]);
const runtimePrefixes = ["--tp-"];
const requiredRootTokens = new Map([
["--stage", "var(--bg)"],
["--surface", "var(--bg-elev)"],
["--surface-2", "var(--bg-elev-2)"],
["--surface-3", "var(--bg-soft)"],
["--panel", "var(--bg-elev)"],
["--border-2", "color-mix(in srgb, var(--fg) 20%, transparent)"],
["--text", "var(--fg)"],
["--text-2", "var(--fg-dim)"],
["--text-3", "var(--fg-faint)"],
["--shadow-color", "#000"],
["--overlay-surface-bg", "var(--bg-elev)"],
]);
const themeStyles = ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"];
const amberExpected = {
dark: {
"--bg": "#090a0c",
"--bg-soft": "#111319",
"--panel": "#191b22",
"--sidebar-bg": "#0c0e12",
"--fg": "#f4f5f7",
"--fg-dim": "#c0c4cc",
"--accent": "#d4632f",
"--border": "#343945",
},
light: {
"--bg": "#f7f8fb",
"--bg-soft": "#eef2f7",
"--panel": "#ffffff",
"--sidebar-bg": "#f3f6fa",
"--fg": "#111827",
"--fg-dim": "#4b5563",
"--accent": "#dd5b28",
"--border": "#d8dee8",
},
};
function listCSSFiles(directory) {
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...listCSSFiles(target));
else if (entry.isFile() && target.endsWith(".css")) files.push(target);
}
return files.sort();
}
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "));
}
function lineNumber(source, index) {
return source.slice(0, index).split("\n").length;
}
function findBlock(source, selector) {
const start = source.indexOf(selector);
if (start < 0) return null;
const open = source.indexOf("{", start + selector.length - 1);
if (open < 0) return null;
let depth = 0;
for (let index = open; index < source.length; index += 1) {
if (source[index] === "{") depth += 1;
else if (source[index] === "}") {
depth -= 1;
if (depth === 0) return source.slice(open + 1, index);
}
}
return null;
}
function declarations(block) {
const result = new Map();
if (!block) return result;
for (const match of block.matchAll(/(--[a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g)) {
result.set(match[1], match[2].trim());
}
return result;
}
function normalize(value) {
return value.replace(/\s+/g, " ").trim().toLowerCase();
}
function resolveToken(name, tokens, seen = new Set()) {
if (seen.has(name)) return null;
const value = tokens.get(name);
if (!value) return null;
const alias = value.match(/^var\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/);
if (!alias) return normalize(value);
const nextSeen = new Set(seen);
nextSeen.add(name);
return resolveToken(alias[1], tokens, nextSeen);
}
const cssFiles = listCSSFiles(sourceRoot);
const sources = new Map(cssFiles.map((file) => [file, stripComments(fs.readFileSync(file, "utf8"))]));
const definitions = new Set();
const errors = [];
for (const source of sources.values()) {
for (const match of source.matchAll(/^\s*(--[a-zA-Z0-9_-]+)\s*:/gm)) definitions.add(match[1]);
for (const match of source.matchAll(/(?<=[{;])\s*(--[a-zA-Z0-9_-]+)\s*:/g)) definitions.add(match[1]);
}
for (const [file, source] of sources) {
for (const match of source.matchAll(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?=,|\))/g)) {
const token = match[1];
const next = source[match.index + match[0].length];
const hasFallback = next === ",";
const runtimeOwned = runtimeTokens.has(token) || runtimePrefixes.some((prefix) => token.startsWith(prefix));
if (retiredTokens.has(token)) {
errors.push(`${path.relative(frontendRoot, file)}:${lineNumber(source, match.index)} references retired ${token}`);
} else if (!definitions.has(token) && !hasFallback && !runtimeOwned) {
errors.push(`${path.relative(frontendRoot, file)}:${lineNumber(source, match.index)} references undefined ${token}`);
}
}
}
const mainStyles = sources.get(path.join(sourceRoot, "styles.css"));
if (!mainStyles) {
errors.push("src/styles.css is missing");
} else {
const rootTokens = declarations(findBlock(mainStyles, ":root {"));
for (const [token, expected] of requiredRootTokens) {
const actual = rootTokens.get(token);
if (!actual) errors.push(`src/styles.css base :root is missing ${token}`);
else if (normalize(actual) !== normalize(expected)) {
errors.push(`src/styles.css base :root must define ${token}: ${expected}; (found ${actual})`);
}
}
for (const style of themeStyles) {
const darkSelector = `:root[data-theme-style="${style}"]`;
const lightSelector = `:root[data-theme="light"][data-theme-style="${style}"]`;
const autoLightSelector = `:root[data-theme-style="${style}"]:not([data-theme])`;
if (!mainStyles.includes(`${darkSelector} {`)) errors.push(`src/styles.css is missing ${style} dark theme selector`);
if (!mainStyles.includes(`${lightSelector} {`)) errors.push(`src/styles.css is missing ${style} forced-light selector`);
if (!mainStyles.includes(`${autoLightSelector} {`)) errors.push(`src/styles.css is missing ${style} auto-light selector`);
}
const workbenchRefresh = mainStyles.slice(mainStyles.indexOf("* Native Workbench refresh"));
if (/^:root\s*\{/m.test(workbenchRefresh)) {
errors.push("Native Workbench refresh must not override named dark theme palettes");
}
if (/^:root\[data-theme="light"\]\s*\{/m.test(workbenchRefresh)) {
errors.push("Native Workbench refresh must not override named forced-light theme palettes");
}
if (/^\s*:root:not\(\[data-theme\]\)\s*\{/m.test(workbenchRefresh)) {
errors.push("Native Workbench refresh must not override named auto-light theme palettes");
}
const darkAmber = new Map([
...rootTokens,
...declarations(findBlock(mainStyles, ':root[data-theme-style="amber"] {')),
]);
const lightAmber = new Map([
...rootTokens,
...declarations(findBlock(mainStyles, ':root[data-theme="light"] {')),
...declarations(findBlock(mainStyles, ':root[data-theme="light"][data-theme-style="amber"] {')),
]);
for (const [mode, tokens] of [["dark", darkAmber], ["light", lightAmber]]) {
for (const [token, expected] of Object.entries(amberExpected[mode])) {
const actual = resolveToken(token, tokens);
if (actual !== normalize(expected)) {
errors.push(`Amber ${mode} ${token} must resolve to ${expected} (found ${actual ?? "undefined"})`);
}
}
}
}
if (errors.length > 0) {
console.error("Theme token contract check failed:");
for (const error of errors) console.error(`- ${error}`);
process.exit(1);
}
console.log(`Theme token contract OK (${cssFiles.length} CSS files, ${definitions.size} defined tokens).`);
+6
View File
@@ -54,8 +54,14 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
"remote-store.test.ts": "test:remote",
"remote-error-ux.test.tsx": "test:remote",
"remote-hosts-page.test.tsx": "test:remote",
"remote-connect-wizard.test.tsx": "test:remote (needs the css stub register)",
"add-project-entries.test.ts": "test:remote",
"remote-secret-dialog.test.tsx": "test:remote",
"remote-server-panel.test.tsx": "test:remote (needs the svg stub register)",
"remote-session-surface.test.tsx": "test:remote (needs the svg stub register)",
"remote-running-reconcile.test.ts": "test:remote",
"remote-project-tree.test.tsx": "test:remote",
"statusbar-workspace.test.tsx": "test:remote",
"updater-shared-state.test.tsx": "test:updater",
"window-state-ordering.test.ts": "test:window-state",
}));
+2 -2
View File
@@ -1,8 +1,8 @@
// Loader hook for tsx-based tests: static asset imports (SVG logos, etc.)
// Loader hook for tsx-based tests: static asset imports (SVG logos, styles)
// resolve to an empty string default export so component tests can import
// components whose transitive store/component chain pulls in an asset.
export async function load(url, context, nextLoad) {
if (url.endsWith(".svg") || url.endsWith(".png") || url.endsWith(".webp")) {
if (url.endsWith(".svg") || url.endsWith(".png") || url.endsWith(".webp") || url.endsWith(".css")) {
return { format: "module", source: "export default \"\";", shortCircuit: true };
}
return nextLoad(url, context);
@@ -26,8 +26,10 @@ const {
shouldOpenTodoPanelByDefault,
shouldShowTodoPanel,
todoBatchKey,
todoContinueTarget,
todoDismissalKey,
todoPanelScope,
todoPresentationStatus,
} = await import(moduleUrl);
const completedTodos = [
@@ -39,6 +41,15 @@ const activeTodos = [
{ content: "Ship the fix", status: "pending" },
];
assert.equal(todoPresentationStatus("in_progress", { running: true, pendingPrompt: false }), "in_progress", "an active turn renders its current todo as in progress");
assert.equal(todoPresentationStatus("in_progress", { running: false, pendingPrompt: false }), "paused", "an idle or restored turn renders a stale current todo as ready to continue");
assert.equal(todoPresentationStatus("in_progress", { running: true, pendingPrompt: true }), "waiting", "a prompt-blocked turn renders its current todo as waiting for the user");
assert.equal(todoPresentationStatus("completed", { running: false, pendingPrompt: false }), "completed", "completed todos remain completed");
assert.equal(todoContinueTarget("tab-a", "tab-a", { ready: true, running: false, pendingPrompt: false }), "tab-a", "continue targets the exact idle visible tab");
assert.equal(todoContinueTarget("tab-a", "tab-b", { ready: true, running: false, pendingPrompt: false }), null, "a rapid tab switch prevents stale todo routing");
assert.equal(todoContinueTarget("tab-a", "tab-a", { ready: true, running: true, pendingPrompt: false }), null, "running turns cannot receive a duplicate continue submission");
assert.equal(todoContinueTarget("tab-a", "tab-a", { ready: true, running: false, pendingPrompt: true }), null, "pending prompts must be answered instead of bypassed by continue");
assert.deepEqual(
resolveTodoPanelTodos([], undefined),
[],
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,76 @@
// Run: tsx src/__tests__/add-project-entries.test.ts
// Source-contract test: the add-project entry points must offer the remote
// connection action next to the local one, and the removed sidebar quick
// button must stay gone.
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: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
console.log("\nAdd-project entry matrix");
const here = dirname(fileURLToPath(import.meta.url));
const treeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
const addControlsSource = readFileSync(resolve(here, "../components/ProjectTreeAddControls.tsx"), "utf8");
const hookSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const locales = ["en", "zh", "zh-TW"].map((name) =>
readFileSync(resolve(here, `../locales/${name}.ts`), "utf8"),
);
// Workbench "+" menu: three items, remote opens the wizard flow.
ok(
/onRemote: \(\) => \{ closeMenu\(\); openRemoteConnectFlow\(\); \}/.test(treeSource) &&
/key: "remote-connection"[\s\S]*?onSelect: onRemote/.test(addControlsSource),
"workbench + menu offers the remote connection item wired to the wizard flow",
);
ok(
/key: "blank-project"[\s\S]*?key: "open-local-folder"[\s\S]*?key: "remote-connection"/.test(addControlsSource),
"workbench + menu keeps blank project and existing folder before remote connection",
);
// Classic/creation "+": no longer a direct picker; renders a menu that
// contains the remote connection item.
ok(
/project-tree__add-project[\s\S]*?aria-haspopup="menu"/.test(addControlsSource),
"classic/creation + button opens a menu instead of the direct picker",
);
ok(
/ProjectTreeHeaderAddControl[\s\S]*?items=\{classicHeaderAddItems\}/.test(treeSource),
"classic/creation + menu is the two-item local/remote list",
);
// Empty state: primary local + secondary remote.
ok(
/project-tree__empty-primary[\s\S]*?ProjectTreeRemoteAction[\s\S]*?openRemoteConnectFlow/.test(treeSource) &&
/project-tree__empty-secondary/.test(addControlsSource),
"empty state pairs the local action with a remote connection action",
);
// The wizard mounts through the creation hook, outside the startup bundle.
ok(
/lazy\(\(\) => import\("\.\/RemoteConnectWizardEntry"\)/.test(hookSource),
"remote wizard loads lazily through useProjectCreation",
);
// Removed sidebar quick button: gone from App.tsx and no locale key remains.
ok(!/sidebar__new--remote/.test(appSource), "removed sidebar quick button leaves no JSX behind");
ok(!/creation\.sidebar\.remote/.test(appSource), "no creation.sidebar.remote usage in App.tsx");
for (const [index, source] of locales.entries()) {
ok(!/creation\.sidebar\.remote/.test(source), `creation.sidebar.remote removed from locale #${index + 1}`);
ok(/"projectTree\.remoteConnection"/.test(source), `projectTree.remoteConnection present in locale #${index + 1}`);
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -447,7 +447,7 @@ ok(
/const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
/if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
/onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
/submitDisabled=\{!controllerReady\}/.test(appSource),
/submitDisabled=\{remoteSurfaceActive \? !remoteComposerReady \|\| !remoteComposerProfileReady : !controllerReady\}/.test(appSource),
"welcome prompts and composer submit share the controller readiness gate",
);
@@ -500,10 +500,10 @@ ok(
"rewind previews warn on incomplete coverage and only authorize file overwrite after a conflict confirmation",
);
ok(
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
/hydrating=\{runtimeTransitioning \|\| transcriptHydrating\}/.test(appSource),
"Welcome is suppressed only until transcript history has loaded",
ok(/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
/hydrating=\{transcriptHydrating \|\| \(runtimeTransitioning && !navigationTargetDataReady\)\}/.test(appSource) &&
/surfaceCommitToken=\{surfaceCommitToken\}/.test(appSource) && /onSurfacePaintReady=\{handleSurfacePaintReady\}/.test(appSource),
"Welcome stays suppressed through target data commit and navigation settles only after paint readiness",
);
ok(
@@ -91,9 +91,14 @@ ok(cancelled === 1, "Escape cancels the dialog");
const here = dirname(fileURLToPath(import.meta.url));
const treeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
const addControlsSource = readFileSync(resolve(here, "../components/ProjectTreeAddControls.tsx"), "utf8");
const flowSource = readFileSync(resolve(here, "../components/BlankProjectFlow.tsx"), "utf8");
const creationSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
ok(/key: "blank-project"[\s\S]*openBlankProjectFlow\(\)/.test(treeSource), "workbench menu starts the blank-project flow");
ok(
/onBlank: \(\) => \{ closeMenu\(\); openBlankProjectFlow\(\); \}/.test(treeSource) &&
/key: "blank-project"[\s\S]*onSelect: onBlank/.test(addControlsSource),
"workbench menu starts the blank-project flow",
);
ok(/CreateBlankProject\(draft\.parentDirectory, projectName\)/.test(flowSource), "the dialog delegates atomic directory creation to Go");
ok(/await onOpenProject\(createdPath\)/.test(flowSource), "the new directory reuses workspace navigation and registration");
ok(/lazy\(\(\) => import\("\.\/BlankProjectFlow"\)/.test(creationSource), "the creation flow stays outside the startup bundle");
@@ -22,6 +22,9 @@ const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
const settingsEntrySource = readFileSync(resolve(here, "../components/SettingsPanelEntry.tsx"), "utf8");
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
const remoteWizardEntrySource = readFileSync(resolve(here, "../components/RemoteConnectWizardEntry.tsx"), "utf8");
const remoteWizardSource = readFileSync(resolve(here, "../components/RemoteConnectWizard.tsx"), "utf8");
const projectCreationSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
const markdownSource = readFileSync(resolve(here, "../components/Markdown.tsx"), "utf8");
const localPathLinksSource = readFileSync(resolve(here, "../lib/localPathLinks.ts"), "utf8");
const i18nSource = readFileSync(resolve(here, "../lib/i18n.tsx"), "utf8");
@@ -62,6 +65,13 @@ ok(
!settingsSource.includes('import "./CompactRatioSettings.css"'),
"Settings CSS stays in the lazy production entry without breaking direct module tests",
);
ok(
remoteWizardEntrySource.includes('import "./RemoteConnectWizard.css"') &&
remoteWizardEntrySource.includes('from "./RemoteConnectWizard"') &&
projectCreationSource.includes('import("./RemoteConnectWizardEntry")') &&
!remoteWizardSource.includes('import "./RemoteConnectWizard.css"'),
"Remote wizard CSS stays in its lazy production entry without breaking direct module tests",
);
ok(
!appSource.includes("openAllHistory") &&
!appSource.includes("cmd-history") &&
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { activeWorkBusyNoticeText } from "../lib/capabilityMutations";
import { t } from "../lib/i18n";
import { effortSwitchNoticeText, modelSwitchNoticeText } from "../lib/useController";
import { effortSwitchNoticeText, modelSwitchNoticeText } from "../lib/controllerSwitchNotices";
function ok(value: unknown, message: string) {
if (!value) throw new Error(message);
@@ -82,6 +82,28 @@ console.log("\nturn checkpoint submission binding");
eq(checkpoint(state, "u11"), 1, "the next normal turn maps independently after a no-TurnDone command");
}
{
const current = reducer(initialState, {
type: "history_replace",
items: [{ kind: "user", id: "rev-12", text: "new" }],
startTurn: 0,
totalTurns: 1,
hasOlder: false,
revision: 12,
digest: "digest-12",
});
const stale = reducer(current, {
type: "history_replace",
items: [{ kind: "user", id: "rev-11", text: "stale" }],
startTurn: 0,
totalTurns: 1,
hasOlder: false,
revision: 11,
digest: "digest-11",
});
eq(stale, current, "an older history revision cannot replace the current transcript projection");
}
{
let state = submit(initialState, "activity A", 15);
state = reducer(state, { type: "unsend" });
@@ -271,7 +271,7 @@ console.log("\ncomposer image capability");
});
eq(sent.length, 1, "switching to a text-only model still sends the image ref for tool use");
ok(toastText().includes("will not receive images directly"), "text-only send warns about direct image input without blocking");
ok(toastText().includes("image-understanding model") || toastText().includes("图片理解模型"), "text-only send points to the image-understanding setting");
eq(document.querySelector(".composer__prompt") === null, true, "image-input warning does not render inside the composer layout");
ok(sent[0]?.submit?.includes("@.reasonix/attachments/mock.png") === true, "submitted text retains the local image attachment ref");
@@ -281,6 +281,39 @@ console.log("\ncomposer image capability");
dom.window.close();
}
{
const dom = installDom();
const sent: string[] = [];
installBridgeApp({
SavePastedImage: async () => ".reasonix/attachments/mock.png",
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root } = await renderComposer({
imageInputEnabled: false,
imageUnderstandingEnabled: true,
onSend: (display) => sent.push(display),
});
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
await act(async () => {
textarea().dispatchEvent(imagePasteEvent(file));
await flushTimers();
await flushTimers();
});
await waitFor(() => contextItemCount() === 1);
await act(async () => {
sendButton().click();
await flushTimers();
});
eq(sent.length, 1, "configured image-understanding fallback still sends the image turn");
eq(toastText(), "", "configured image-understanding fallback suppresses the obsolete warning");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
@@ -160,6 +160,9 @@ console.log("diagnostics settings page");
ok(calls[0] === false, "initial load must request static report (includeSessionRuntime=false)");
ok((rootEl.textContent || "").includes("skill.missing_description"), "warnings must render");
ok(rootEl.querySelector(".diag-summary"), "health summary must render");
const frontendToggle = rootEl.querySelector('[data-testid="frontend-diagnostics-settings"] [role="switch"]');
ok(frontendToggle, "frontend diagnostics switch must be visible in the production diagnostics settings page");
ok(frontendToggle?.getAttribute("aria-checked") === "false", "frontend diagnostics switch starts off");
const runtimeToggle = rootEl.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
ok(runtimeToggle, "runtime toggle must exist");
@@ -3,18 +3,18 @@ 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.deliveryIncompleteTitle"], "Delivery checks are not complete");
assert.equal(en["notice.deliveryIncompleteBody"], "The response was generated, but verification and review still need to be completed.");
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.deliveryIncompleteTitle"], "交付检查尚未完成");
assert.equal(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.deliveryIncompleteTitle"], "交付檢查尚未完成");
assert.equal(zhTW["notice.deliveryIncompleteBody"], "內容已經產生,但驗證和複審步驟尚未完成。");
assert.equal(zhTW["notice.deliveryIncompleteContinue"], "繼續檢查");
assert.equal(zhTW["notice.deliveryRequirementTask"], "剩餘實作工作");
@@ -77,7 +77,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(/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(/terminalSurfaceOpen && !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,154 @@
import assert from "node:assert/strict";
import {
analyzeFrontendDiagnosticAnomalies,
createFrontendDiagnostics,
isFrontendDiagnosticsBuild,
} from "../lib/frontendDiagnostics";
let now = 10_000;
const diagnostics = createFrontendDiagnostics({
maxEvents: 32,
now: () => now,
randomID: () => "0123456789abcdef0123456789abcdef",
environment: () => ({
buildCommit: "test-commit",
buildChannel: "canary",
platform: "windows",
userAgent: "Reasonix test",
devicePixelRatio: 1.25,
viewportWidth: 1440,
viewportHeight: 900,
language: "zh-CN",
reducedMotion: false,
}),
});
diagnostics.record("app", "app.surface", { tabId: "private-tab", transcriptText: "PRIVATE_TEXT" });
assert.equal(diagnostics.getSnapshot().eventCount, 0, "idle recorder ignores events");
diagnostics.start();
now += 10;
diagnostics.record("browser", "click", {
target: "button",
path: "C:\\Users\\private-user\\secret",
token: "PRIVATE_TOKEN",
text: "PRIVATE_TEXT",
});
now += 10;
diagnostics.record("transcript", "transcript.row-measure", {
rowIndex: 44,
rowKind: "answer",
layoutVersion: "1:42",
layoutVariant: "text-flow",
estimateSource: "static",
estimatedSize: 1_800,
measuredSize: 420,
sizeDelta: -1_380,
contentRevision: 3,
});
diagnostics.record("history", "history.items-patch", {
patchCount: 2,
contentRevision: 4,
});
diagnostics.record("transcript", "transcript.scroll-write", {
source: "recovery-end",
owner: "recovery",
writeKind: "scrollToIndex",
targetIndex: "LAST",
targetTop: 720,
previousMode: "manual",
canClaimTail: false,
tailCommand: true,
});
diagnostics.mark();
for (let index = 0; index < 40; index += 1) {
now += 1;
diagnostics.record("browser", "scroll", { scrollTop: index * 10, scrollHeight: 2_400, clientHeight: 800 });
}
diagnostics.record("transcript", "transcript.scroll-write", {
source: "recovery-end",
owner: "recovery",
writeKind: "scrollToIndex",
targetIndex: "LAST",
targetTop: 720,
});
diagnostics.record("transcript", "transcript.row-measure", {
layoutVersion: "1:42",
layoutVariant: "text-flow",
estimateSource: "static",
measuredSize: 420,
});
diagnostics.record("history", "history.items-patch", { patchCount: 2, contentRevision: 4 });
diagnostics.record("workspace", "workspace.session-directory", {
workspaceSessions: 7,
visibleSessions: 5,
hiddenSessions: 2,
hiddenByCollapsed: 1,
hiddenByTruncation: 1,
runtimeOnlySessions: 1,
recoveryCopies: 2,
changeReason: "recovery-copy",
workspaceRoot: "C:\\Users\\private-user\\workspace",
topicId: "private-topic-id",
label: "PRIVATE_TITLE",
});
const active = diagnostics.getSnapshot();
assert.equal(active.status, "recording");
assert.equal(active.eventCount, 32, "ring buffer remains bounded");
assert.ok(active.droppedEventCount > 0, "overflow is visible in the summary");
assert.equal(active.markerCount, 0, "evicted markers are not counted as retained");
const payload = diagnostics.stop();
assert.equal(payload.schemaVersion, 1);
assert.equal(payload.manifest.platform, "windows");
assert.equal(payload.events[payload.events.length - 1]?.type, "stop");
const serialized = JSON.stringify(payload);
assert.equal(serialized.includes("scrollToIndex"), true, "scroll writer details remain available for analysis");
assert.equal(serialized.includes("targetIndex"), true, "scroll target remains available for analysis");
assert.equal(serialized.includes("recovery-end"), true, "scroll transition source remains available for analysis");
assert.equal(serialized.includes("history.items-patch"), true, "history patch event remains available for analysis");
assert.equal(serialized.includes("layoutVersion"), true, "row layout version remains available for analysis");
assert.equal(serialized.includes("layoutVariant"), true, "row layout variant remains available for analysis");
assert.equal(serialized.includes("estimateSource"), true, "height estimate source remains available for analysis");
assert.equal(serialized.includes("workspace.session-directory"), true, "sidebar directory event remains available for analysis");
assert.equal(serialized.includes("workspaceSessions"), true, "sidebar session counts remain available for analysis");
assert.ok(Array.isArray(payload.summary.anomalies), "payload includes automatic anomaly analysis");
for (const forbidden of ["PRIVATE_TEXT", "PRIVATE_TOKEN", "private-user", "private-tab", "path", "token", "tabId"]) {
assert.equal(serialized.includes(forbidden), false, `payload excludes ${forbidden}`);
}
const frozenCount = diagnostics.getSnapshot().eventCount;
diagnostics.record("browser", "scroll", { scrollTop: 999 });
assert.equal(diagnostics.getSnapshot().eventCount, frozenCount, "stopped recorder remains frozen");
assert.equal(isFrontendDiagnosticsBuild("test", false), true);
assert.equal(isFrontendDiagnosticsBuild("canary", false), true);
assert.equal(isFrontendDiagnosticsBuild("preview", false), true);
assert.equal(isFrontendDiagnosticsBuild("stable", true), true);
assert.equal(isFrontendDiagnosticsBuild("stable", false), false);
assert.deepEqual(analyzeFrontendDiagnosticAnomalies([
{ t: 0, type: "navigation.begin", intent: 7 },
{ t: 1, type: "workspace.session-directory", workspaceSessions: 8 },
{ t: 2, type: "workspace.session-directory", workspaceSessions: 7 },
{ t: 3, type: "navigation.settle", intent: 7 },
{ t: 4, type: "history.older-request", trigger: "viewport-user" },
{ t: 5, type: "transcript.scroll-write", owner: "unknown-writer" },
]), [
{ code: "settle-before-paint-ready", intent: 7 },
{ code: "navigation-session-count-changed", intent: 7, count: 2 },
{ code: "viewport-older-without-user-input", count: 1 },
{ code: "unknown-scroll-writer", count: 1 },
], "automatic analysis reports ordering, paging, catalog, and writer anomalies");
assert.deepEqual(analyzeFrontendDiagnosticAnomalies([
{ t: 0, type: "history.viewport-permit" },
{ t: 1, type: "history.older-request", trigger: "viewport-user" },
]), [], "a viewport page request consumes one explicit user permit without a false anomaly");
assert.deepEqual(analyzeFrontendDiagnosticAnomalies([
{ t: 0, type: "navigation.begin", intent: 9 },
{ t: 1, type: "navigation.settle", intent: 9, outcome: "failed" },
]), [], "a failed data terminal may release its mask without a paint-ready false positive");
console.log("frontend diagnostics tests passed");
@@ -10,7 +10,8 @@ import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { activateGoalAndSubmitOnTab } from "../lib/goalSubmit";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
import { historySliceFromMessages } from "./mockHistorySlice";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -133,6 +134,8 @@ window.go = {
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
HistorySliceForTab: async (tabId: string, request: HistorySliceRequest) =>
historySliceFromMessages(tabId, [], request),
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
@@ -201,23 +204,27 @@ await waitForActive("tab-a");
eq(controller?.activeTabId, "tab-a", "harness starts on tab A");
const sourceTabId = "tab-a";
const pending = activateGoalAndSubmitOnTab({
tabId: sourceTabId,
displayText: "Cross-tab safe goal",
submitText: "/ui-ux-pro-max Cross-tab safe goal",
structured: {
display: "/ui-ux-pro-max Cross-tab safe goal",
input: "Cross-tab safe goal",
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
},
sendToTab: (tabId, goal, display, submit, structured) => {
if (!controller) throw new Error("controller missing");
return controller.sendToTab(tabId, display, submit, undefined, structured, {
goal,
collaborationMode: "normal",
toolApprovalMode: "ask",
});
},
let pending!: Promise<void>;
await act(async () => {
pending = activateGoalAndSubmitOnTab({
tabId: sourceTabId,
displayText: "Cross-tab safe goal",
submitText: "/ui-ux-pro-max Cross-tab safe goal",
structured: {
display: "/ui-ux-pro-max Cross-tab safe goal",
input: "Cross-tab safe goal",
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
},
sendToTab: (tabId, goal, display, submit, structured) => {
if (!controller) throw new Error("controller missing");
return controller.sendToTab(tabId, display, submit, undefined, structured, {
goal,
collaborationMode: "normal",
toolApprovalMode: "ask",
});
},
});
await flushPromises();
});
// While the atomic bridge call for A is suspended, the UI switches to tab B.
@@ -0,0 +1,45 @@
import { act } from "react";
export function installTranscriptRaceClock(targetWindow: Window) {
let clockNow = 10_000;
let nextTimer = 1;
const timers = new Map<number, { dueAt: number; run: () => void }>();
const originalDateNow = Date.now;
const originalSetTimeout = targetWindow.setTimeout;
const originalClearTimeout = targetWindow.clearTimeout;
Date.now = () => clockNow;
targetWindow.setTimeout = ((handler: TimerHandler, timeout = 0, ...args: unknown[]) => {
const id = nextTimer++;
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 targetWindow.setTimeout;
targetWindow.clearTimeout = ((id: number | undefined) => {
if (id !== undefined) timers.delete(id);
}) as typeof targetWindow.clearTimeout;
const advanceClock = async (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;
});
};
const restore = () => {
Date.now = originalDateNow;
targetWindow.setTimeout = originalSetTimeout;
targetWindow.clearTimeout = originalClearTimeout;
};
return { advanceClock, restore };
}
@@ -18,7 +18,7 @@ assert.match(controller, /throw new Error\(t\("history\.failedLoadHistory"\)\)/,
assert.match(controller, /retrySessionHistory/, "retry path is exported");
assert.match(
transcript,
/if \(!virtuosoReadyRef\.current \|\| !stick\.current\) return;\n followGrowingTail\(\);\n \}, \[footerHeight, followGrowingTail, stick\]\);/,
/if \(hydrating \|\| !virtuosoReadyRef\.current \|\| !stick\.current\) return;\n followGrowingTail\(\);\n \}, \[footerHeight, followGrowingTail, hydrating, stick\]\);/,
"footer resize is routed through the coalesced tail-follow path without depending on item appends",
);
assert.match(controller, /shouldPreferResidentHistory\(resetSurface, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot");
@@ -158,5 +158,17 @@ eq(archivedError[0]?.kind === "tool" && archivedError[0].status, "error", "archi
eq(archivedError[0]?.kind === "tool" && archivedError[0].dataArchived, true, "archived failed result is still loadable on demand");
eq(archivedError[0]?.kind === "tool" && archivedError[0].error, "error: permission denied", "archived failed result keeps bounded error preview");
const localOnlyDupes = toolItems([
{ role: "user", content: "first" },
{ role: "tool", toolCallId: "__reasonix_local_only__", toolName: "__reasonix_local_only__", content: "partial one" },
{ role: "user", content: "second" },
{ role: "tool", toolCallId: "__reasonix_local_only__", toolName: "__reasonix_local_only__", content: "partial two" },
] as HistoryMessage[]);
eq(localOnlyDupes.length, 2, "each interrupted-turn local-only record hydrates as its own tool item");
eq(
localOnlyDupes[0]?.id !== localOnlyDupes[1]?.id,
true,
"repeated __reasonix_local_only__ toolCallIds uniquify so transcript row keys cannot collide after restart",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
const packageJSON = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
const boxedRule = styles.match(/\.md\s+\.katex\s+\.fbox\s*\{([^}]*)\}/s)?.[1] ?? "";
if (!/width:\s*100%\s*;/.test(boxedRule)) {
throw new Error("KaTeX boxed frame must keep a standards-compatible width fallback");
}
if (!/width:\s*-webkit-fill-available\s*;/.test(boxedRule)) {
throw new Error("KaTeX boxed frame must fill the containing row in WebKit");
}
if (packageJSON.packageManager !== "[email protected]") {
throw new Error(`frontend package manager pin drifted: ${packageJSON.packageManager ?? "missing"}`);
}
console.log("katex boxed CSS/package contract: PASS");
@@ -202,6 +202,53 @@ console.log("\nmarkdown history rendering");
await act(async () => root4.unmount());
}
// ── a worker result cannot replace the reader's mid-document fallback ────────
{
rootEl.className = "transcript";
rootEl.scrollTop = 400;
Object.defineProperty(rootEl, "scrollHeight", { configurable: true, value: 1_000 });
Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: 300 });
(globalThis as { Worker?: unknown }).Worker = class {};
let resolveParse: ((result: ReturnType<typeof parseMarkdown>) => void) | null = null;
const deferred = new MarkdownWorkerClient({
createWorker: () => Promise.resolve({
onmessage: null,
onerror: null,
postMessage(request) {
resolveParse = (result) => {
const message = { data: { id: request.id, result } };
(this.onmessage as ((event: unknown) => void) | null)?.(message);
};
},
terminate() {},
}),
});
setMarkdownWorkerClientForTest(deferred);
const root5 = createRoot(rootEl);
const text = Array.from({ length: 60 }, (_, index) => `# Anchor ${index + 1}\n\nBody ${index + 1}.`).join("\n\n");
await act(async () => {
root5.render(<MarkdownHistory text={text} entryId="md-history-mid-read" fallback={<div className="md">{text}</div>} />);
});
await act(async () => {
resolveParse?.(parseMarkdown(text));
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(!rootEl.querySelector(".md[data-markdown-blocks]"), "worker completion keeps the full fallback while the reader is away from bottom");
ok(rootEl.textContent?.includes("Anchor 1"), "the reader's prefix remains mounted during the deferred handoff");
rootEl.scrollTop = 700;
await act(async () => {
rootEl.dispatchEvent(new dom.window.Event("scroll"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(rootEl.querySelector('.md[data-markdown-blocks="120"]'), "returning to bottom commits the cached parsed blocks");
ok(rootEl.textContent?.includes("Anchor 60"), "the parsed tail is visible after the safe handoff");
await act(async () => root5.unmount());
rootEl.className = "";
delete (globalThis as { Worker?: unknown }).Worker;
setMarkdownWorkerClientForTest(newSpyClient());
}
// ── progressive mounting for huge documents ──────────────────────────────────
{
rootEl.className = "transcript";
@@ -219,13 +266,13 @@ console.log("\nmarkdown history rendering");
return { top, bottom: top + height, left: 0, right: 0, width: 0, height, x: 0, y: top, toJSON: () => ({}) };
};
const text = Array.from({ length: 420 }, (_, i) => `Paragraph ${i} with some *content*.`).join("\n\n");
const root5 = createRoot(rootEl);
const root6 = createRoot(rootEl);
const writeTranscriptOffset = (_owner: "block-window-prepend", top: number) => {
rootEl.scrollTop = top;
return true;
};
await act(async () => {
root5.render(
root6.render(
<TranscriptScrollWriteProvider value={writeTranscriptOffset}>
<MarkdownHistory text={text} entryId="md-history-huge" fallback={null} />
</TranscriptScrollWriteProvider>,
@@ -276,7 +323,7 @@ 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(
root6.render(
<TranscriptScrollWriteProvider value={writeTranscriptOffset}>
<MarkdownHistory text={replacement} entryId="md-history-huge-replacement" fallback={null} />
</TranscriptScrollWriteProvider>,
@@ -288,7 +335,7 @@ console.log("\nmarkdown history rendering");
ok(rootEl.textContent?.includes("Replacement 419"), "the replacement's newest block mounts immediately");
await intersectSentinel("[data-markdown-older-sentinel]", true);
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "120", "a replacement document re-arms viewport paging");
await act(async () => root5.unmount());
await act(async () => root6.unmount());
dom.window.HTMLElement.prototype.getBoundingClientRect = originalRect;
rootEl.className = "";
}
@@ -1,7 +1,14 @@
// Run: tsx src/__tests__/navigation-surface-transition.test.ts
import { readFileSync } from "node:fs";
import { guardBackendNavigationResult, settleNavigationSurfaceIntent } from "../lib/navigationSurfaceTransition";
import {
advanceSurfacePaintCommit,
beginNavigationSurfaceState,
guardBackendNavigationResult,
markNavigationTargetMasked,
settleNavigationSurfaceIntent,
settleNavigationSurfaceState,
} from "../lib/navigationSurfaceTransition";
let passed = 0;
let failed = 0;
@@ -24,6 +31,42 @@ ok(active === 3, "coalesced B completion cannot release C's mask");
active = settleNavigationSurfaceIntent(active, 3);
ok(active === null, "the latest completion releases its own mask");
let surface = beginNavigationSurfaceState(9);
surface = markNavigationTargetMasked(surface, 8);
ok(surface?.phase === "source-retained", "a stale request cannot replace the retained source");
surface = markNavigationTargetMasked(surface, 9, "tab-target");
ok(surface?.phase === "target-masked", "the latest request mounts its target under the mask");
surface = settleNavigationSurfaceState(surface, 8);
ok(surface?.intent === 9, "a stale paint terminal cannot release the latest mask");
surface = settleNavigationSurfaceState(surface, 9);
ok(surface === null, "the matching paint terminal releases the mask");
let paint = advanceSurfacePaintCommit({ attempts: 0, stableFrames: 0 }, {
rendered: true, placementReady: true, geometryReady: true, geometryKey: "755:1200:445",
});
ok(paint.outcome === undefined, "one paint frame cannot expose the target");
paint = advanceSurfacePaintCommit(paint.progress, {
rendered: true, placementReady: true, geometryReady: true, geometryKey: "859:1200:341",
});
ok(paint.outcome === undefined, "a changed viewport geometry restarts the stability gate");
paint = advanceSurfacePaintCommit(paint.progress, {
rendered: true, placementReady: true, geometryReady: true, geometryKey: "859:1200:341",
});
ok(paint.outcome === "ready", "two stable geometry frames commit the target");
let stalled = { attempts: 0, stableFrames: 0 };
let recoveryRequests = 0;
let degraded: string | undefined;
for (let frame = 0; frame < 180; frame += 1) {
const decision = advanceSurfacePaintCommit(stalled, {
rendered: true, placementReady: false, geometryReady: false, geometryKey: "755:0:0",
});
stalled = decision.progress;
if (decision.requestRecovery) recoveryRequests += 1;
degraded = decision.outcome;
}
ok(recoveryRequests === 2, "stalled placement receives two bounded recovery opportunities");
ok(degraded === "degraded", "the second failed placement terminates without permanent loading");
let reasserted = "";
const currentAccepted = await guardBackendNavigationResult({
intent: 4,
@@ -56,13 +99,21 @@ ok(await staleAcceptedPromise === false, "a stale backend-activating result is r
ok(reasserted === "tab.reveal-background:tab-stale", "stale reassertion receives the mutating target identity");
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
ok(appSource.includes("flushSync(() => setNavigationSurfaceIntent(intent))"), "navigation masking commits synchronously before the Wails await");
ok(appSource.includes("items={runtimeTransitioning ? [] : displayItems}"), "App removes source transcript rows during navigation");
const surfaceHookSource = readFileSync(new URL("../lib/useNavigationSurface.ts", import.meta.url), "utf8");
const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
ok(surfaceHookSource.includes("flushSync(() => {"), "navigation masking commits synchronously before the Wails await");
ok(surfaceHookSource.includes("setPreserved(rendered?.items.length ? rendered : null)"), "the last stable transcript is retained during navigation");
ok(appSource.includes("items={visibleTranscriptItems}"), "the visible transcript is decoupled from the hydrating target");
ok(appSource.includes("transcript-navigation-overlay"), "navigation renders a blocking transcript overlay");
ok(/\.transcript-navigation-overlay\s*\{[\s\S]*?background:\s*var\(--chat-bg, var\(--bg\)\)/.test(stylesSource), "the navigation overlay is opaque while target rows settle");
ok(appSource.includes("live={runtimeTransitioning ? undefined : state.live}"), "App removes source live output during navigation");
ok(appSource.includes("hidden={composerSurfaceHidden || undefined}"), "App keeps the composer mounted but hidden during navigation");
ok(appSource.includes("composer-decision-host--footprint-hidden"), "App preserves the composer footprint during navigation");
ok(!appSource.includes("hidden={composerSurfaceHidden || undefined}"), "navigation no longer collapses the composer footprint");
ok(appSource.includes("inert={composerSurfaceHidden ? true : undefined}"), "the hidden composer is inert during navigation");
ok(appSource.includes("!runtimeTransitioning && showTodos"), "source-session Todo content is isolated");
ok(appSource.includes("!runtimeTransitioning && rewindState"), "source-session rewind content is isolated");
ok(appSource.includes("{showTodos && ("), "target Todo footprint is laid out below the mask");
ok(appSource.includes("{rewindState && ("), "target rewind footprint is laid out below the mask");
ok(/\.footer--navigation-hidden\s*\{[\s\S]*?visibility:\s*hidden;[\s\S]*?pointer-events:\s*none;/.test(stylesSource), "masked target footer cannot paint or receive input");
ok(appSource.includes('style={navigationSurface?.phase === "source-retained"') && appSource.includes("const visibleDecisionSurface = decisionSurface"), "target-masked paint uses the target footer geometry");
ok((appSource.match(/guardBackendNavigationResult\(\{/g) ?? []).length === 2, "both Reveal paths guard stale backend activation results");
const switchFolderSource = appSource.slice(
appSource.indexOf("const switchFolder = useCallback"),
@@ -72,7 +123,8 @@ ok(switchFolderSource.includes("const navigationIntentSeq = noteNavigationIntent
ok(switchFolderSource.includes("beginNavigationSurface(navigationIntentSeq)"), "workspace navigation masks the source surface before Wails");
ok(switchFolderSource.includes("pickWorkspace(navigationIntentSeq)"), "folder-pick navigation carries the shared intent into the controller");
ok(switchFolderSource.includes("switchWorkspace(path, navigationIntentSeq)"), "direct workspace navigation carries the shared intent into the controller");
ok(switchFolderSource.includes("settleNavigationSurface(navigationIntentSeq)"), "workspace navigation compare-clears its surface mask");
ok(switchFolderSource.includes("settleNavigationSurface(navigationIntentSeq)"), "workspace request completion advances the target under its surface mask");
ok(surfaceHookSource.includes("navigation.paint-ready"), "surface settlement is diagnosed only from target paint readiness");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -4,6 +4,7 @@ import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { initialState, reducer, useController, type Item } from "../lib/useController";
import type { NavigationResult } from "../lib/navigationSurfaceTransition";
import { historySliceFromMessages } from "./mockHistorySlice";
import type { AppBindings } from "../lib/bridge";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, WireEvent } from "../lib/types";
@@ -335,17 +336,21 @@ await act(async () => {
eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session");
let resumePromise: Promise<void> | undefined;
let resumeNavigation: NavigationResult<void> | undefined;
let resumeSurfaceSettled = false;
await act(async () => {
resumePromise = controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
resumeNavigation = controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
void resumeNavigation?.surfaceReady.then(() => { resumeSurfaceSettled = true; });
await flushPromises();
});
eq(resumeSurfaceSettled, false, "Resume releases navigation acquisition before target history settles");
eq(controller?.state.ask?.id, "pre-response-resume", "Resume accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
await act(async () => {
resumeRPCGate.resolve();
await resumePromise;
await resumeNavigation?.surfaceReady;
await flushPromises();
});
eq(resumeSurfaceSettled, true, "Resume surfaceReady resolves after authoritative history and reconciliation");
eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state");
eq(controller?.state.ask?.id, "replayed-runtime-resumed", "Resume RPC ask emitted before return is restored after reset/history hydration");
ok(promptReplayCalls > 0, "Resume completion performs a tab-scoped pending-prompt replay");
@@ -356,15 +361,15 @@ await act(async () => {
await flushPromises();
});
const replayCallsBeforeChannelOpen = promptReplayCalls;
let channelPromise: Promise<void> | undefined;
let channelNavigation: NavigationResult<void> | undefined;
await act(async () => {
channelPromise = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a");
channelNavigation = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a");
await flushPromises();
});
eq(controller?.state.ask?.id, "pre-response-channel", "channel-open accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
await act(async () => {
channelRPCGate.resolve();
await channelPromise;
await channelNavigation?.surfaceReady;
await flushPromises();
});
eq(controller?.state.ask?.id, "replayed-runtime-channel", "channel-open ask emitted before return is restored after reset/history hydration");
@@ -1,11 +1,14 @@
import assert from "node:assert/strict";
import {
enqueueProjectTreeArchive,
projectTreeSessionArchiveTargetKey,
projectTreeTopicArchiveTargetKey,
projectTreeTrashingTopics,
runProjectTreeArchiveJob,
} from "../lib/projectTreeArchive";
import {
invalidateProjectTreeTopicLoads,
projectTreeFolderKeyForSession,
projectTreeWithoutTopics,
} from "../lib/projectTreeTopic";
import type { ProjectNode } from "../lib/types";
@@ -105,6 +108,7 @@ async function testPendingEndsOnlyAfterCanonicalReload() {
let pending = true;
const job = runProjectTreeArchiveJob({
archive: async () => {},
commit: () => {},
reload: () => reloadGate.promise,
finishPending: () => { pending = false; },
recover: async () => {},
@@ -118,19 +122,65 @@ async function testPendingEndsOnlyAfterCanonicalReload() {
}
async function testFailedArchiveRestoresVisibilityBeforeRecoveryReload() {
const backendGate = deferred<void>();
let pending = true;
let tombstones = new Set<string>();
let recoveryObservedPending: boolean | null = null;
const job = runProjectTreeArchiveJob({
archive: async () => { throw new Error("busy"); },
archive: () => backendGate.promise,
commit: () => { tombstones = projectTreeTrashingTopics(tombstones, "topic-a", true); },
reload: async () => {},
finishPending: () => { pending = false; },
recover: async () => { recoveryObservedPending = pending; },
});
await Promise.resolve();
assert.equal(pending, true);
assert.equal(tombstones.has("topic-a"), false, "pending backend work must not hide the topic");
assert.equal(projectTreeWithoutTopics(
[{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }],
tombstones,
).length, 1);
backendGate.reject(new Error("busy"));
assert.equal(await job, false);
assert.equal(tombstones.has("topic-a"), false, "rejected archives never commit a tombstone");
assert.equal(recoveryObservedPending, false);
}
function testArchiveTargetsDoNotCollideAcrossRows() {
assert.notEqual(
projectTreeTopicArchiveTargetKey("project", "/a", "shared"),
projectTreeTopicArchiveTargetKey("project", "/b", "shared"),
"same-id topics in different projects keep independent confirmations",
);
assert.notEqual(
projectTreeSessionArchiveTargetKey("/a/one.jsonl"),
projectTreeSessionArchiveTargetKey("/a/two.jsonl"),
"sessions in one topic keep independent confirmations",
);
}
function testSessionArchiveReloadsItsOwningFolder() {
const tree: ProjectNode[] = [{
key: "project-a",
kind: "project",
label: "Project A",
children: [{
key: "topic-a",
kind: "topic",
label: "Topic A",
children: [{
key: "session-a",
kind: "session",
label: "Session A",
sessionPath: " /sessions/a.jsonl ",
}],
}],
}];
assert.equal(projectTreeFolderKeyForSession(tree, "/sessions/a.jsonl"), "project-a");
assert.equal(projectTreeFolderKeyForSession(tree, "/sessions/missing.jsonl"), "");
}
function testProjectTreeWiresEveryRaceGuard() {
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"),
@@ -140,12 +190,24 @@ function testProjectTreeWiresEveryRaceGuard() {
join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"),
"utf8",
);
assert.match(archiveSource, /invalidateProjectTreeTopicLoads[\s\S]*optimisticallyRemoveTopic\(topicId\)/);
const sessionMenuSource = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTreeSessionArchiveMenu.tsx"),
"utf8",
);
assert.match(archiveSource, /await archive\(\)[\s\S]*commit\(\)[\s\S]*await reload\(\)/);
assert.match(archiveSource, /commitArchiveTombstone\(topicId\)[\s\S]*invalidateProjectTreeTopicLoads[\s\S]*optimisticallyRemoveTopic\(topicId\)/);
assert.match(source, /projectTreeWithoutTopics\(asArray\(page\.items\), currentArchiveTombstones\(\)\)/);
assert.match(archiveSource, /archiveQueueRef\.current\.catch[\s\S]*\.then\(async \(\) =>/);
assert.match(archiveSource, /return previous\.catch\(\(\) => undefined\)\.then\(work\)/);
assert.match(archiveSource, /pendingLoads = targets\.map[\s\S]*onReloadStarted[\s\S]*await Promise\.all\(pendingLoads\)/);
assert.match(archiveSource, /onReloadStarted: \(\) => releaseArchiveTombstone\(topicId\)/);
assert.match(archiveSource, /await refreshRef\.current\(reloadOptions\)[\s\S]*finally \{[\s\S]*endTrashingTopic\(topicId\)/);
assert.match(archiveSource, /finishPending: \(\) => endTrashingTopic\(topicId\)/);
assert.match(source, /const topicMenuOpen = menuNodeKey === key/);
assert.match(source, /onContextMenu=\{openTopicMenu\}/);
assert.match(source, /node\.sessionPath \?\? ""/);
assert.match(sessionMenuSource, /disabled: !sessionPath \|\| blocked \|\| busy/);
assert.match(source, /sessionPath=\{sessionPath\} blocked=\{archiveBlocked \|\| topicTrashing\}/);
assert.match(source, /void trashSession\(sessionPath\)/);
assert.match(archiveSource, /projectTreeFolderKeyForSession\(treeRef\.current, sessionPath\)[\s\S]*refreshRef\.current\(reloadOptions\)/);
}
await testLatePreArchivePageCannotReinsertTopic();
@@ -154,5 +216,7 @@ await testPostCommitRestoreCanWinWhilePendingIndicatorFinishes();
await testConcurrentArchivesReachBackendSerially();
await testPendingEndsOnlyAfterCanonicalReload();
await testFailedArchiveRestoresVisibilityBeforeRecoveryReload();
testArchiveTargetsDoNotCollideAcrossRows();
testSessionArchiveReloadsItsOwningFolder();
testProjectTreeWiresEveryRaceGuard();
console.log("project tree archive race: 7 passed");
console.log("project tree archive race: 9 passed");
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { summarizeProjectTreeSessions } from "../lib/projectTreeDiagnostics";
import type { ProjectNode } from "../lib/types";
const topic = (id: string, extra: Partial<ProjectNode> = {}): ProjectNode => ({
key: `topic-${id}`,
kind: "topic",
label: id,
topicId: id,
...extra,
});
const tree: ProjectNode[] = [{
key: "project-a",
kind: "project",
label: "Project A",
root: "private-root",
children: [
topic("one"),
topic("two"),
topic("three"),
topic("four"),
topic("five"),
topic("six", { runtimeOnly: true, recoveryCopyCount: 2, running: true }),
],
}, {
key: "global",
kind: "global_folder",
label: "Global",
children: [topic("global-one", { kind: "global_topic", topicId: "global-one", pinned: true })],
}];
const summary = summarizeProjectTreeSessions({
tree,
visibleTree: tree,
expanded: new Set(["project-a"]),
showAllTopics: new Set(),
classicTruncationActive: true,
queryActive: false,
timeFilterActive: false,
projectNodeKey: (node) => node.key,
isActive: (node) => node.topicId === "one",
isUnread: (node) => node.topicId === "two",
});
assert.equal(summary.workspaceSessions, 7);
assert.equal(summary.visibleSessions, 5, "classic preview plus collapsed global folder hides two rows");
assert.equal(summary.hiddenSessions, 2);
assert.equal(summary.hiddenByTruncation, 1);
assert.equal(summary.hiddenByCollapsed, 1);
assert.equal(summary.runtimeOnlySessions, 1);
assert.equal(summary.recoveryCopySessions, 1);
assert.equal(summary.recoveryCopies, 2);
assert.equal(summary.runningSessions, 1);
assert.equal(summary.unreadSessions, 1);
assert.equal(summary.pinnedSessions, 1);
assert.equal(summary.activeSessions, 1);
assert.equal(summary.activeVisibleSessions, 1);
const filtered = summarizeProjectTreeSessions({
tree,
visibleTree: [{ ...tree[0], children: [tree[0].children?.[0] ?? topic("one")] }],
expanded: new Set(["project-a"]),
showAllTopics: new Set(["project-a"]),
classicTruncationActive: false,
queryActive: true,
timeFilterActive: false,
projectNodeKey: (node) => node.key,
});
assert.equal(filtered.workspaceSessions, 7);
assert.equal(filtered.visibleSessions, 1);
assert.equal(filtered.hiddenByFilter, 6);
assert.equal(filtered.hiddenByCollapsed, 0);
assert.equal(filtered.hiddenByTruncation, 0);
console.log("project tree diagnostics tests passed");
@@ -7,10 +7,14 @@ import { createRoot } from "react-dom/client";
import {
AddProviderPanel,
ProviderAccessCard,
providerAccessGroups,
SettingsPanel,
type ProviderAccessGroup,
} from "../components/SettingsPanel";
import { LocaleProvider } from "../lib/i18n";
import type { ProviderView } from "../lib/types";
import type { AppBindings } from "../lib/bridge";
import type { ProviderPresetView, ProviderView, SettingsView } from "../lib/types";
import { baseSettings } from "../test-support/settingsTestFixtures";
let passed = 0;
let failed = 0;
@@ -46,6 +50,8 @@ 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);
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => undefined });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => undefined });
window.scrollTo = () => {};
window.matchMedia = () => ({
matches: true,
@@ -112,6 +118,15 @@ function group(providers: ProviderView[]): ProviderAccessGroup {
};
}
function customGroup(provider: ProviderView): ProviderAccessGroup {
return {
...group([provider]),
id: `custom:${provider.name}`,
label: provider.name,
builtIn: false,
};
}
function renderCard(
providerGroup: ProviderAccessGroup,
actions: {
@@ -125,7 +140,6 @@ function renderCard(
group={providerGroup}
busy={false}
fetching={false}
defaultProvider=""
editing={null}
kinds={["anthropic", "openai"]}
onEdit={() => undefined}
@@ -217,6 +231,121 @@ ok(
"card-level removal submits every grouped provider in one action",
);
let removedCustomProviders: string[] = [];
await act(async () => {
root.render(renderCard(customGroup({ ...deepSeekAnthropic, name: "my-proxy", builtIn: false }), {
onDelete: async (providers) => {
removedCustomProviders = providers.map((provider) => provider.name);
},
}));
await flushPromises();
});
const defaultCustomMoreButton = rootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
ok(defaultCustomMoreButton?.disabled === false, "configured custom providers keep the removal menu available");
await act(async () => {
defaultCustomMoreButton?.click();
await flushPromises();
});
let deleteDefaultCustomButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
await act(async () => {
deleteDefaultCustomButton?.click();
await flushPromises();
});
deleteDefaultCustomButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
ok(deleteDefaultCustomButton !== undefined, "configured custom provider can be confirmed for deletion");
await act(async () => {
deleteDefaultCustomButton?.click();
await flushPromises();
});
ok(
removedCustomProviders.join(",") === "my-proxy",
"configured custom provider removal submits the selected provider",
);
const defaultCustomSettings: SettingsView = baseSettings("standard");
const defaultCustomProvider: ProviderView = {
...deepSeekAnthropic,
name: "my-proxy",
builtIn: false,
added: true,
kind: "openai",
baseUrl: "https://proxy.example/v1",
models: ["my-model"],
default: "my-model",
apiKeyEnv: "",
keySet: true,
configured: true,
webSearch: false,
serverWebSearchCapability: false,
};
defaultCustomSettings.defaultModel = "my-proxy/my-model";
defaultCustomSettings.providers = [defaultCustomProvider];
defaultCustomSettings.providerKinds = ["openai"];
let removedDefaultCustomProviders: string[] = [];
window.go = {
main: {
App: {
Settings: async () => defaultCustomSettings,
RemoveProviderAccesses: async (names: string[]) => {
removedDefaultCustomProviders = [...names];
defaultCustomSettings.providers = defaultCustomSettings.providers.filter((provider) => !names.includes(provider.name));
},
} as Partial<AppBindings> as AppBindings,
},
};
const settingsRootEl = document.createElement("div");
document.body.appendChild(settingsRootEl);
const settingsRoot = createRoot(settingsRootEl);
await act(async () => {
settingsRoot.render(
<LocaleProvider>
<SettingsPanel
initialTab="models"
initialFocus={{ target: "model-access", requestId: 1 }}
desktopPlatform="linux"
onClose={() => undefined}
onChanged={() => undefined}
onUseSubagent={() => undefined}
/>
</LocaleProvider>,
);
await flushPromises();
await flushPromises();
});
const defaultCustomSettingsMoreButton = settingsRootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
ok(
defaultCustomSettingsMoreButton?.disabled === false,
"current default custom provider keeps the removal menu available",
);
await act(async () => {
defaultCustomSettingsMoreButton?.click();
await flushPromises();
});
let deleteCurrentDefaultButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
await act(async () => {
deleteCurrentDefaultButton?.click();
await flushPromises();
});
deleteCurrentDefaultButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
ok(deleteCurrentDefaultButton !== undefined, "current default custom provider can be confirmed for deletion");
await act(async () => {
deleteCurrentDefaultButton?.click();
await flushPromises();
await flushPromises();
});
ok(
removedDefaultCustomProviders.join(",") === "my-proxy",
"current default custom provider removal reaches the settings backend",
);
await act(async () => {
settingsRoot.unmount();
});
settingsRootEl.remove();
await act(async () => {
root.render(renderCard(group([
deepSeekAnthropic,
@@ -244,6 +373,7 @@ await act(async () => {
root.render(
<LocaleProvider>
<AddProviderPanel
key="official-installed"
mode="official"
kinds={["anthropic", "openai"]}
officialProviders={[{ ...deepSeekAnthropic, name: "deepseek" }]}
@@ -266,6 +396,142 @@ const installedOfficialChoice = Array.from(rootEl.querySelectorAll("button"))
ok(installedOfficialChoice?.disabled === true, "installed official providers cannot be added again");
ok(installedOfficialChoice?.textContent?.includes("Added") === true, "installed official providers show their backend status");
const openCodePreset = (
id: string,
label: string,
providerNames: string[],
displayTier: "primary" | "advanced" | "compatibility",
displaySection: "go" | "zen" = "go",
): ProviderPresetView => ({
id,
label,
description: `${label} description`,
keyEnv: displaySection === "zen" ? "OPENCODE_API_KEY" : "OPENCODE_GO_API_KEY",
recommended: id === "opencode-go-recommended",
displayGroup: "opencode",
displaySection,
displayTier,
routeKind: id === "opencode-go-recommended" ? "bundle" : "anthropic",
providerNames,
models: ["model"],
added: false,
status: "available",
statusProviderNames: [],
keySet: false,
requiresKey: true,
configured: false,
});
await act(async () => {
root.render(
<LocaleProvider>
<AddProviderPanel
key="opencode-quick-setup"
mode="official"
kinds={["anthropic", "openai", "responses"]}
officialProviders={[{ ...deepSeekAnthropic, name: "deepseek" }]}
providerPresets={[
openCodePreset("opencode-go-recommended", "OpenCode Go (Recommended)", ["opencode-go", "opencode-go-anthropic", "opencode-go-responses"], "primary"),
openCodePreset("opencode-go-anthropic", "OpenCode Go Anthropic", ["opencode-go-anthropic"], "advanced"),
openCodePreset("opencode-go-responses", "OpenCode Go Responses", ["opencode-go-responses"], "advanced"),
openCodePreset("opencode-go", "OpenCode Go Chat", ["opencode-go"], "compatibility"),
openCodePreset("opencode-zen-anthropic", "OpenCode Zen", ["opencode-zen-anthropic"], "primary", "zen"),
]}
busy={false}
onMode={() => undefined}
onCancel={() => undefined}
onAddOfficial={async () => undefined}
onAddPreset={async () => undefined}
onViewPresetConflict={() => undefined}
onResetPreset={async () => undefined}
onAddCustom={() => undefined}
/>
</LocaleProvider>,
);
await flushPromises();
});
ok(rootEl.textContent?.includes("OpenCode Go Anthropic") === false, "OpenCode setup hides protocol-specific provider entries");
ok(rootEl.textContent?.includes("Advanced routes") === false, "OpenCode setup does not expose an advanced-route decision");
ok(rootEl.textContent?.includes("Compatibility") === false, "OpenCode setup hides compatibility entries from new users");
const featuredProviders = rootEl.querySelector(".provider-featured-grid");
ok(
featuredProviders?.textContent?.includes("DeepSeek") === true
&& featuredProviders.textContent.includes("OpenCode Go"),
"OpenCode Go and DeepSeek are presented as same-level recommended providers",
);
ok(
featuredProviders?.querySelectorAll(".provider-template-card").length === 2
&& featuredProviders.textContent?.includes("OpenCode Zen") === false,
"the top-level provider row contains only DeepSeek and OpenCode Go",
);
ok(
rootEl.querySelector<HTMLInputElement>('input[placeholder*="OPENCODE_GO_API_KEY"]') !== null,
"OpenCode Go opens directly on its API key field",
);
ok(
Array.from(rootEl.querySelectorAll("button")).some((button) => button.textContent?.trim() === "Connect and start using"),
"OpenCode Go setup exposes one outcome-oriented primary action",
);
ok(rootEl.querySelector('[role="tablist"]') === null, "OpenCode Go quick setup does not ask users to choose a configuration mode");
const chooseAnotherProvider = Array.from(rootEl.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Choose another provider") as HTMLButtonElement | undefined;
await act(async () => {
chooseAnotherProvider?.click();
await flushPromises();
});
ok(rootEl.textContent?.includes("OpenCode Zen") === true, "OpenCode Zen remains an independent product choice");
const openCodeProviders: ProviderView[] = [
{ ...deepSeekAnthropic, builtIn: false, name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
{ ...deepSeekAnthropic, builtIn: false, name: "opencode-go-anthropic", baseUrl: "https://opencode.ai/zen/go", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
{ ...deepSeekAnthropic, builtIn: false, name: "opencode-go-responses", kind: "responses", baseUrl: "https://opencode.ai/zen/go/v1", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
];
const openCodeGroups = providerAccessGroups(openCodeProviders, ((key: string) => key) as never);
ok(openCodeGroups.length === 1, "installed OpenCode Go routes render as one product connection");
ok(openCodeGroups[0]?.id === "custom:opencode-go", "OpenCode Go connection has a stable product-level identity");
await act(async () => {
root.render(renderCard(openCodeGroups[0]));
await flushPromises();
});
const routeSettings = rootEl.querySelector<HTMLDetailsElement>("details.provider-route-settings");
ok(routeSettings !== null && routeSettings.open === false, "installed OpenCode Go keeps protocol routes collapsed by default");
ok(
routeSettings?.querySelector("summary")?.textContent?.trim() === "Advanced route settings",
"installed OpenCode Go exposes route controls only through an advanced disclosure",
);
let removedOpenCodeProviders: string[] = [];
await act(async () => {
root.render(renderCard(openCodeGroups[0], {
onDelete: async (providers) => {
removedOpenCodeProviders = providers.map((provider) => provider.name);
},
}));
await flushPromises();
});
const openCodeMoreButton = rootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
await act(async () => {
openCodeMoreButton?.click();
await flushPromises();
});
let removeOpenCodeButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
await act(async () => {
removeOpenCodeButton?.click();
await flushPromises();
});
removeOpenCodeButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
await act(async () => {
removeOpenCodeButton?.click();
await flushPromises();
});
ok(
removedOpenCodeProviders.join(",") === "opencode-go,opencode-go-anthropic,opencode-go-responses",
"OpenCode Go group removal submits every installed route atomically",
);
await act(async () => {
root.unmount();
});
@@ -51,13 +51,17 @@ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.win
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
window.scrollTo = () => {};
function renderPicker(candidates: string[], visionCapability: "configurable" | "unsupported" = "configurable") {
function renderPicker(
candidates: string[],
visionCapability: "configurable" | "unsupported" = "configurable",
visionModels: string[] = [],
) {
return (
<LocaleProvider>
<ProviderEditorModelPicker
candidates={candidates}
selectedModels={[]}
visionModels={[]}
visionModels={visionModels}
visionCapability={visionCapability}
contextWindows={{}}
disabled={false}
@@ -101,6 +105,22 @@ await act(async () => {
});
ok(rootEl.textContent?.includes("No image input") === true, "known text-only DeepSeek models show a read-only image capability");
ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "text-only model card does not render a second image checkbox");
await act(async () => {
root.render(renderPicker(["deepseek-v4-flash-vision-exp"], "unsupported", ["deepseek-v4-flash-vision-exp"]));
await flushPromises();
});
ok(rootEl.textContent?.includes("No image input") !== true, "pinned DeepSeek vision SKU does not show the text-only image label");
ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "pinned DeepSeek vision SKU does not render a configurable image checkbox");
await act(async () => {
root.render(renderPicker(
["deepseek-v4-flash", "deepseek-v4-flash-vision-exp"],
"configurable",
["deepseek-v4-flash-vision-exp"],
));
await flushPromises();
});
ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 4, "configurable DeepSeek models expose image-input checkboxes");
ok(rootEl.textContent?.includes("No image input") !== true, "configurable DeepSeek models do not use the read-only image-unsupported label");
ok(providerSupportsServerWebSearch("responses", "https://api.deepseek.com"), "DeepSeek Responses exposes server-side web search");
ok(providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com/anthropic"), "DeepSeek Anthropic exposes server-side web search");
ok(!providerSupportsServerWebSearch("openai", "https://api.deepseek.com"), "DeepSeek Chat Completions does not expose server-side web search");
@@ -137,12 +157,12 @@ ok(
"backend vision capability overrides frontend endpoint inference",
);
ok(
providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com" }) === "unsupported",
"older backend payloads retain the endpoint-based vision fallback",
providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com" }) === "configurable",
"official DeepSeek Settings uses the same image-input checkboxes as other providers",
);
ok(
providerVisionCapabilityForView({ kind: "anthropic", baseUrl: "https://eu.deepseek.com/anthropic" }) === "unsupported",
"older backend payloads treat regional DeepSeek subdomains as text-only",
providerVisionCapabilityForView({ kind: "anthropic", baseUrl: "https://eu.deepseek.com/anthropic" }) === "configurable",
"regional DeepSeek Settings also expose per-model image-input checkboxes",
);
ok(
providerVisionCapabilityForView({ kind: "responses", baseUrl: "https://deepseek.com" }) === "configurable",
@@ -271,7 +291,8 @@ await act(async () => {
const webSearchSwitch = rootEl.querySelector<HTMLInputElement>('input[role="switch"]');
ok(rootEl.textContent?.includes("Server-side web search") === true, "DeepSeek Responses editor separates service capabilities from model selection");
ok(webSearchSwitch?.checked === true, "curated DeepSeek Responses capability is enabled in the editor");
ok(rootEl.textContent?.includes("No image input") === true, "DeepSeek Responses editor labels image input as unsupported");
ok(rootEl.textContent?.includes("Image input") === true, "DeepSeek Responses editor exposes per-model image-input checkboxes");
ok(rootEl.textContent?.includes("No image input") !== true, "DeepSeek Responses editor does not use the read-only image-unsupported label");
await act(async () => {
root.render(renderProviderEditor(longCatAnthropicProvider));
@@ -28,6 +28,7 @@ function ok(cond: boolean, label: string) {
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
const noticesSource = readFileSync(resolve(here, "../lib/controllerNotices.ts"), "utf8");
console.log("\nquiet recovery notifications");
@@ -38,7 +39,7 @@ ok(!appSource.includes("AcknowledgeTabRecovery"), "App does not expose recovery
ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery compare controls");
ok(!appSource.includes("recovery.openOriginalFailed"), "App does not carry recovery compare failure text");
ok(controllerSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
ok(noticesSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText, code))"), "raw quiet notices are skipped before localization");
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text, code))"), "localized quiet notices are skipped before rendering");
@@ -0,0 +1,458 @@
// Run: tsx src/__tests__/remote-connect-wizard.test.tsx
import React from "react";
import { JSDOM } from "jsdom";
import { act } from "react";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { AppBindings } from "../lib/bridge";
import type { RemoteDirEntry, RemoteHostView } 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;
}
}
console.log("\nRemote connect wizard (three steps)");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
const [{ createRoot }, { RemoteConnectWizard }, { LocaleProvider }, { useRemoteStore }] = await Promise.all([
import("react-dom/client"),
import("../components/RemoteConnectWizard"),
import("../lib/i18n"),
import("../store/remote"),
]);
// Bridge call tape: every remote method appends "<name>:<detail>".
const tape: string[] = [];
const savedHosts: RemoteHostView[] = [
{ id: "gpu-box", label: "gpu-box", host: "192.168.1.10", port: 22, user: "dev", identityFile: "~/.ssh/id_ed25519", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
{ id: "pw-box", label: "pw-box", host: "10.0.0.8", port: 22, user: "ops", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false, passwordSet: true },
];
let hostCount = 0;
function setInput(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
input.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
}
function buttonByText(text: string): HTMLButtonElement | undefined {
return [...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === text);
}
async function flush(ticks = 20) {
for (let i = 0; i < ticks; i++) await Promise.resolve();
}
// Real-time wait: the ConnectRemoteHost mock holds the connecting step for a
// 20ms macrotask so the log panel renders; microtask flushes cannot cover it.
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
const dirs: Record<string, Array<{ name: string; path: string; isDir: boolean }>> = {
"/home/dev": [
{ name: "projects", path: "/home/dev/projects", isDir: true },
{ name: "notes.txt", path: "/home/dev/notes.txt", isDir: false },
],
"/home/dev/projects": [{ name: "app", path: "/home/dev/projects/app", isDir: true }],
"/home/dev/projects/web": [],
};
const slowDirectory = Promise.withResolvers<RemoteDirEntry[]>();
// Connection attempt counter: the first attempt fails (the wizard stays on
// the connecting step so its log panel stays observable); the retry succeeds.
let connectAttempts = 0;
// Platform-gate attempt counter: the first check models a Windows SSH host
// (uname reports MINGW64); later checks pass.
let platformAttempts = 0;
// Last AddRemoteHost payload, for credential-mode assertions.
let lastAddInput: RemoteHostInput | undefined;
window.go = { main: { App: {
async RemoteHosts() {
tape.push("RemoteHosts");
return savedHosts.slice();
},
async AddRemoteHost(input: RemoteHostView & { label?: string; host?: string }) {
lastAddInput = input as unknown as RemoteHostInput;
tape.push(`AddRemoteHost:${input.label}:${input.host}:${(input as RemoteHostInput).credentialMode ?? ""}`);
hostCount += 1;
const view = { id: `new-${hostCount}`, label: String(input.label), host: String(input.host), port: 22, user: "", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "npm", credentialMode: "remote", useSSHConfig: false } as RemoteHostView;
savedHosts.push(view);
return view;
},
async UpdateRemoteHost(id: string, input: RemoteHostView & { label?: string; host?: string }) {
tape.push(`UpdateRemoteHost:${id}:${input.host}`);
return savedHosts.find((h) => h.id === id) ?? savedHosts[0] as RemoteHostView;
},
async RemoteLastWorkspace() {
tape.push("RemoteLastWorkspace");
return "/home/dev";
},
async ListRemoteDir(_hostId: string, path: string) {
tape.push(`ListRemoteDir:${path}`);
if (path === "/slow") return slowDirectory.promise;
if (path === "/fast") {
return [{ name: "latest", path: "/fast/latest", isDir: true, size: 0, mtimeUnix: 0, symlink: false }];
}
return (dirs[path] ?? []).map((entry) => ({ ...entry, size: 0, mtimeUnix: 0, symlink: false }));
},
async MkdirRemote(_hostId: string, path: string) {
tape.push(`MkdirRemote:${path}`);
return undefined;
},
async ConnectRemoteHost(hostId: string) {
tape.push(`ConnectRemoteHost:${hostId}`);
// Hold 60ms so the connecting step mounts, then emit the kernel status
// per attempt: first stopped+error → waitForRemoteConnection rejects
// immediately and the wizard stays on the connecting step; later
// attempts connected → the flow proceeds to the platform check.
await new Promise<void>((resolve) => setTimeout(resolve, 60));
connectAttempts += 1;
if (connectAttempts === 1) {
useRemoteStore.getState().applyStatus({ hostId, state: "stopped", error: "ssh: handshake failed" });
return undefined;
}
useRemoteStore.getState().applyStatus({ hostId, state: "connected" });
return undefined;
},
// Platform gate: the first check models a Windows SSH host (uname reports
// MINGW64), later checks pass.
async CheckRemotePlatform(hostId: string) {
tape.push(`CheckRemotePlatform:${hostId}`);
platformAttempts += 1;
if (platformAttempts === 1) {
throw new Error('remote host platform check failed: unsupported remote OS "MINGW64_NT-10.0-19045" (V1 supports Linux and macOS)');
}
return undefined;
},
async PickRemoteIdentityFile() {
tape.push("PickRemoteIdentityFile");
return "/home/dev/.ssh/id_wizard";
},
async OpenRemoteWorkspace(hostId: string, workspace: string) {
tape.push(`OpenRemoteWorkspace:${hostId}:${workspace}`);
},
async OpenRemoteProjectTab(hostId: string, workspace: string, opts?: { newSession?: boolean }) {
tape.push(`OpenRemoteProjectTab:${hostId}:${workspace}:${opts?.newSession === true}`);
},
async AddRemoteProject(hostId: string, workspace: string) {
tape.push(`AddRemoteProject:${hostId}:${workspace}`);
return { hostId, workspace };
},
} as Partial<AppBindings> as AppBindings } };
function WizardHarness() {
return (
<LocaleProvider>
<RemoteConnectWizard
onRefresh={async () => { tape.push("refresh"); }}
onClose={() => {
tape.push("close");
}}
/>
</LocaleProvider>
);
}
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
await act(async () => {
root.render(<WizardHarness />);
});
await act(async () => flush());
// ── Step ① initial state: stepper rail, config form ──
const railItems = () => [...document.querySelectorAll(".remote-wizard__rail-item")];
ok(railItems().length === 3, "stepper rail lists all three steps");
ok(railItems()[0]?.className.includes("--current") === true, "step 1 is current on open");
ok(railItems().every((item) => !item.className.includes("--done")), "no step is done on open");
ok(document.querySelectorAll(".remote-wizard__seg").length === 3, "auth, download, and credential mode use segmented sliders");
const hostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
ok(Boolean(hostInput), "config step shows the host input");
ok(document.activeElement === hostInput, "opening the dialog focuses the first field");
await act(async () => {
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
await Promise.resolve();
});
ok(document.activeElement === buttonByText("Next"), "Shift+Tab from the first field wraps to the last action");
await act(async () => {
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await Promise.resolve();
});
ok(document.activeElement === hostInput, "Tab from the last action wraps to the first field");
// ── Empty host+user: footer alert names both missing fields ──
{
const userInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.placeholder.includes("root"));
await act(async () => {
if (hostInput) setInput(hostInput, "");
if (userInput) setInput(userInput, "");
await Promise.resolve();
});
await act(async () => {
buttonByText("Next")?.click();
await Promise.resolve();
});
const alert = document.querySelector(".remote-wizard__footer [role='alert']");
const text = alert?.textContent ?? "";
ok(text.includes("Host") && text.includes("username") && text.includes("Password"), "empty form reports host, username, and password");
ok(text.includes("⚠"), "footer alert uses the warning mark");
await act(async () => {
if (userInput) setInput(userInput, "root");
await Promise.resolve();
});
}
// ── Saved-host suggestion: focus → dropdown → prefill ──
await act(async () => {
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
await Promise.resolve();
});
const suggestion = document.querySelector<HTMLButtonElement>(".remote-wizard__suggest-list button");
ok(Boolean(suggestion), "focusing the host input lists saved SSH connections");
await act(async () => {
suggestion?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
ok(hostInput?.value === "192.168.1.10", "picking a suggestion prefills the host");
const keyInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.value.includes("id_ed25519"));
ok(Boolean(keyInput), "saved key auth switches the form to key mode with the identity file");
await act(async () => {
document.querySelector<HTMLButtonElement>(".remote-wizard__pick-btn")?.click();
await flush();
});
ok(tape.includes("PickRemoteIdentityFile"), "identity-file action uses the native desktop picker");
ok(keyInput?.value === "/home/dev/.ssh/id_wizard", "native picker returns the absolute identity-file path");
{
await act(async () => {
if (hostInput) setInput(hostInput, "");
await Promise.resolve();
});
await act(async () => {
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
await Promise.resolve();
});
const listed = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")].find((b) => b.textContent?.includes("pw-box"));
await act(async () => {
listed?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
const passwordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
ok((passwordInput?.placeholder ?? "").toLowerCase().includes("saved") || (passwordInput?.placeholder ?? "").includes("已保存"), "saved password host keeps a keep-existing placeholder");
await act(async () => {
if (hostInput) setInput(hostInput, "");
await Promise.resolve();
});
await act(async () => {
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
await Promise.resolve();
});
const gpuSuggestion = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")].find((b) => b.textContent?.includes("gpu-box"));
await act(async () => {
gpuSuggestion?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
// ── Next: first connect fails; the wizard stays on the connecting step ──
await act(async () => {
buttonByText("Next")?.click();
await delay(120);
await flush();
});
ok(tape.includes("UpdateRemoteHost:gpu-box:192.168.1.10"), "next on a picked host updates it instead of adding a duplicate");
ok(!tape.some((entry) => entry.startsWith("AddRemoteHost:")), "no AddRemoteHost for a saved host");
// The failure path keeps the connecting step on screen (act has ended and
// the DOM is committed), so counting log lines here is reliable: at least
// two — connecting + failed.
{
const logCount = document.querySelectorAll(".remote-wizard__log-line").length;
ok(logCount >= 2, "connecting step streams the deployment log");
const connectError = document.querySelector(".remote-wizard__connecting .remote-wizard__error");
ok(Boolean(connectError?.textContent?.includes("ssh: handshake failed")), "failed connect surfaces the kernel error");
ok(Boolean(buttonByText("Retry")), "retry action is offered after a failed connect");
await act(async () => {
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await Promise.resolve();
});
ok(document.activeElement === buttonByText("Back to edit"), "Tab after a step transition returns focus to the dialog");
}
// ── Retry #1: SSH connects, but the platform check rejects the host ──
await act(async () => {
buttonByText("Retry")?.click();
await flush();
});
ok(buttonByText("Cancel")?.disabled === true, "retry keeps the wizard busy while the connection is pending");
await act(async () => {
await delay(120);
await flush();
});
ok(tape.includes("CheckRemotePlatform:gpu-box"), "a connected host runs the platform check before the workspace step");
ok(railItems()[1]?.className.includes("--current") === true, "an unsupported OS keeps the wizard on the connecting step");
{
const platformError = document.querySelector(".remote-wizard__connecting .remote-wizard__error");
ok(Boolean(platformError?.textContent?.includes("unsupported remote OS")), "the platform failure surfaces the kernel error");
ok(!tape.some((entry) => entry.startsWith("ListRemoteDir")), "directory browsing never starts for an unsupported host");
ok(Boolean(buttonByText("Retry")), "retry stays available after a platform rejection");
}
// ── Retry #2: the platform check passes and lands on step ③ ──
await act(async () => {
buttonByText("Retry")?.click();
await delay(120);
await flush();
});
ok(railItems()[0]?.className.includes("--done") === true, "step 1 turns done (green check) after advancing");
ok(railItems()[2]?.className.includes("--current") === true, "step 3 is current after connecting");
ok(document.querySelector<HTMLInputElement>(".remote-wizard__path-input")?.value === "/home/dev", "workspace picker starts at RemoteLastWorkspace path");
ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "projects")), "directory entries render");
ok(Boolean([...document.querySelectorAll(".remote-wizard__file")].find((row) => row.textContent?.includes("notes.txt"))), "files render in the tree next to folders");
{
const fileRow = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__file")].find((row) => row.textContent?.includes("notes.txt"));
await act(async () => {
fileRow?.click();
await Promise.resolve();
});
ok(fileRow?.className.includes("--selected") === true, "clicking a file highlights the row");
ok(document.querySelector<HTMLInputElement>(".remote-wizard__path-input")?.value === "/home/dev", "clicking a file selects its parent directory as the workspace");
}
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__dir")].find((b) => b.textContent === "projects")?.click();
await flush();
});
ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "app")), "drilling into a directory lists its children");
ok(!document.querySelector(".remote-wizard__mkdir"), "workspace step has no create-folder controls");
// ── Directory race: an older slow response cannot replace the newer path ──
{
const pathInput = document.querySelector<HTMLInputElement>(".remote-wizard__path-input");
await act(async () => {
if (pathInput) setInput(pathInput, "/slow");
await Promise.resolve();
});
await act(async () => {
buttonByText("Go")?.click();
await flush();
});
await act(async () => {
if (pathInput) setInput(pathInput, "/fast");
await Promise.resolve();
});
await act(async () => {
buttonByText("Go")?.click();
await flush();
});
ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "latest")), "newer directory response renders first");
await act(async () => {
slowDirectory.resolve([{ name: "stale", path: "/slow/stale", isDir: true, size: 0, mtimeUnix: 0, symlink: false }]);
await flush();
});
ok(![...document.querySelectorAll(".remote-wizard__dir")].some((b) => b.textContent === "stale"), "stale directory response cannot overwrite the latest path");
await act(async () => {
if (pathInput) setInput(pathInput, "/home/dev/projects");
await Promise.resolve();
});
}
// ── Finish: pin, open an in-app session tab, then refresh the tree ──
await act(async () => {
buttonByText("Connect and open")?.click();
await flush();
});
ok(tape.includes("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "finish opens the selected workspace in a new remote session tab");
ok(tape.includes("AddRemoteProject:gpu-box:/home/dev/projects"), "finish pins the selected remote workspace");
ok(tape.indexOf("AddRemoteProject:gpu-box:/home/dev/projects") < tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "the workspace is pinned before its session tab opens");
ok(tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true") < tape.indexOf("refresh"), "the project tree refreshes after the session tab opens");
ok(tape.includes("close"), "wizard closes after a successful finish");
await act(async () => root.unmount());
// ── Second harness: brand-new host goes through AddRemoteHost ──
const secondRootEl = document.createElement("div");
document.body.appendChild(secondRootEl);
const secondRoot = createRoot(secondRootEl);
await act(async () => {
secondRoot.render(<WizardHarness />);
});
await act(async () => flush());
const newHostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
const newUserInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.placeholder.includes("root"));
const newPasswordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
await act(async () => {
if (newHostInput) setInput(newHostInput, "10.9.8.7");
if (newUserInput) setInput(newUserInput, "root");
await Promise.resolve();
});
await act(async () => {
if (newPasswordInput) setInput(newPasswordInput, "s3cret");
await Promise.resolve();
});
{
// Credential mode: a segmented control mirroring the download method;
// picking local-proxy must ride the AddRemoteHost payload.
const segButtons = Array.from(document.querySelectorAll<HTMLButtonElement>(".remote-wizard__field .provider-add-segmented__item"));
const localProxy = segButtons.find((b) => b.textContent?.includes("本机") || b.textContent?.includes("this computer"));
ok(Boolean(localProxy), "wizard host form offers the credential-mode segmented control");
await act(async () => {
localProxy?.click();
await Promise.resolve();
});
ok(localProxy?.className.includes("--active") === true, "local-proxy segment highlights when selected");
}
await act(async () => {
buttonByText("Next")?.click();
await flush();
});
ok(tape.some((entry) => entry.startsWith("AddRemoteHost:10.9.8.7:10.9.8.7")), "a new host is added (label defaults to the host)");
ok(lastAddInput?.credentialMode === "local-proxy", `AddRemoteHost carries the chosen credential mode (got ${lastAddInput?.credentialMode})`);
await act(async () => secondRoot.unmount());
// ── Merged finish: source contract for overlapping workspaces ──
const here = dirname(fileURLToPath(import.meta.url));
const wizardSource = readFileSync(resolve(here, "../components/RemoteConnectWizard.tsx"), "utf8");
ok(
/const canonical = project\.merged \? project\.workspace : target;/.test(wizardSource) &&
/OpenRemoteProjectTab\(hostId, canonical, \{ newSession: true \}\)/.test(wizardSource),
"a merged finish opens the tab on the canonical workspace",
);
ok(
/if \(!project\.merged\) \{[\s\S]*?RemoveRemoteProject\(hostId, target\)/.test(wizardSource),
"rollback only removes a pin the wizard actually added (a merge owns none)",
);
ok(
/onMerged\?\.\(t\("remoteWizard\.mergedProject"/.test(wizardSource),
"a merged finish notifies through onMerged",
);
dom.window.close();
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -1,7 +1,7 @@
// Run: tsx src/__tests__/remote-error-ux.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { JSDOM } from "jsdom";
import { act } from "react";
import { createRoot } from "react-dom/client";
@@ -29,6 +29,10 @@ const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body>
(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;
// Pin the locale source to the JSDOM navigator (en-US): Node's own global
// navigator follows the machine's system language, which flips detectLocale
// to Chinese on zh hosts and breaks the English assertions below.
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;
@@ -51,6 +55,7 @@ const host: RemoteHostView = {
proxyJump: "",
defaultWorkspace: "/srv/app",
serveInstall: "auto",
credentialMode: "remote",
useSSHConfig: false,
};
const rawError = "remote: host key mismatch (/home/dev/.ssh/known_hosts:7)";
@@ -1,14 +1,14 @@
// Run: tsx src/__tests__/remote-hosts-page.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { JSDOM } from "jsdom";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { RemoteHostsPage } from "../components/RemoteHostsPage";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import type { RemoteHostView } from "../lib/types";
import type { RemoteHostInput, RemoteHostView } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -61,11 +61,13 @@ let hosts: RemoteHostView[] = [{
proxyJump: "",
defaultWorkspace: "/srv/app",
serveInstall: "auto",
credentialMode: "remote",
useSSHConfig: false,
passwordSet: true,
keyPassphraseSet: true,
}];
let removeCalls = 0;
const recordedUpdates: Array<RemoteHostInput & { id: string }> = [];
let legacyView = { mirrorCount: 0, mirrorBytes: 0, trustFile: false };
let cleanCalls: string[] = [];
const bindings = {
@@ -75,6 +77,10 @@ const bindings = {
removeCalls += 1;
hosts = hosts.filter((host) => host.id !== id);
},
async UpdateRemoteHost(id: string, input: RemoteHostInput) {
recordedUpdates.push({ id, ...input });
return { ...hosts.find((host) => host.id === id)!, ...input, id } as RemoteHostView;
},
async ScanRemoteLegacyWorkbenchData() { return legacyView; },
async CleanRemoteLegacyWorkbenchData(target: "mirrors" | "trust") {
cleanCalls.push(target);
@@ -121,6 +127,25 @@ await act(async () => {
});
ok(document.body.textContent?.includes("saved password will be removed") === true, "explicit clear action is staged until Save");
// Credential mode: the host form offers remote | local-proxy and the choice
// rides the UpdateRemoteHost payload.
{
const modeSelect = Array.from(document.querySelectorAll<HTMLSelectElement>("select"))
.find((sel) => Array.from(sel.options).some((opt) => opt.value === "local-proxy"));
ok(Boolean(modeSelect), "edit form offers the credential-mode select");
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLSelectElement.prototype, "value")?.set;
await act(async () => {
setter?.call(modeSelect, "local-proxy");
modeSelect?.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await flush();
});
await act(async () => {
button("Save")?.click();
await flush();
});
ok(recordedUpdates.length === 1 && recordedUpdates[0].credentialMode === "local-proxy", `save carries the chosen credential mode (got ${JSON.stringify(recordedUpdates.map((u) => u.credentialMode))})`);
}
await act(async () => {
button("Cancel")?.click();
await flush();
@@ -0,0 +1,249 @@
// Run: tsx src/__tests__/remote-project-tree.test.tsx
// Source-contract test: the remote project group's tree behavior — session
// rows, the + action, the remote context menu, the connection badge, and
// the local-action guards — is wired exactly once and in the remote shape.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { activeRemoteProjectAncestorKeys } from "../components/ProjectTreeRemoteGroups";
import type { ProjectNode } 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;
}
}
console.log("\nRemote project tree wiring");
const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
const remoteSource = readFileSync(resolve(here, "../components/ProjectTreeRemoteGroups.tsx"), "utf8");
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const modeActionsSource = readFileSync(resolve(here, "../lib/useComposerModeActions.ts"), "utf8");
const composerSource = readFileSync(resolve(here, "../components/Composer.tsx"), "utf8");
const contentMenuSource = readFileSync(resolve(here, "../components/ComposerContentMenuActions.tsx"), "utf8");
const remoteIntegrationSource = readFileSync(resolve(here, "../lib/useRemoteComposerIntegration.ts"), "utf8");
const topicbarMenuSource = readFileSync(resolve(here, "../components/TopicbarMoreMenuContent.tsx"), "utf8");
const bridgeSource = readFileSync(resolve(here, "../lib/remoteProjectBridge.ts"), "utf8");
const remoteOpenSource = readFileSync(resolve(here, "../../../remote_projects.go"), "utf8");
const explicitEnsureSource = remoteSource.match(
/const ensureRemoteGroupSessions[\s\S]*?\n const openRemoteWindow/,
)?.[0] ?? "";
const sameWorkspaceRemoteTree: ProjectNode[] = [
{ key: "remote-host-a", kind: "project", label: "A", remote: { hostId: "host-a", workspace: "/repo" }, children: [] },
{ key: "remote-host-b", kind: "project", label: "B", remote: { hostId: "host-b", workspace: "/repo" }, children: [] },
];
ok(
JSON.stringify(activeRemoteProjectAncestorKeys(sameWorkspaceRemoteTree, { hostId: "host-b", workspace: "/repo" }, (node) => node.key ?? "")) === JSON.stringify(["remote-host-b"]),
"active remote group expansion uses host-qualified identity instead of local workspace fallback",
);
ok(
/remoteSession: \{ hostId: node\.remote!\.hostId, workspace: node\.remote!\.workspace, name: row\.name, path: row\.path, title: row\.title \}/.test(remoteSource) &&
/openRemoteSessionNode\(remote, openRemoteProject\)/.test(source) &&
/sessionPath: remote\.path, sessionTitle: remote\.title/.test(remoteSource),
"session rows open the matching in-app remote session",
);
ok(
/rows\.map\(\(row\): ProjectNode =>/.test(remoteSource) && /mergeRemoteSessionsIntoTree\(tree, remoteSessions, t\)/.test(source) &&
/root: node\.remote!\.workspace/.test(remoteSource) && /sessionPath: row\.path/.test(remoteSource),
"remote group children render with the active workspace and session identity",
);
ok(
/app\.RemoteProjectSessions\(hostId, workspace\)/.test(remoteSource),
"sessions are fetched through the bridge",
);
ok(
/state !== "connected" && state !== "degraded"/.test(remoteSource),
"session fetch waits for a connected host",
);
ok(
/key: "remote-new-session"[\s\S]*?key: "remote-open-window"[\s\S]*?key: "remote-stop-server"[\s\S]*?key: "remote-unpin"/.test(remoteSource),
"the remote menu exposes new-session, browser, stop, and unpin actions",
);
ok(
/items=\{node\.remote \? remoteProjectMenuItems :/.test(source),
"remote groups swap out the local project menu",
);
ok(
/app\.OpenRemoteProjectTab\(ref\.hostId, ref\.workspace,[\s\S]*?newSession: true/.test(remoteSource) &&
/app\.ConnectRemoteHost\(ref\.hostId\)[\s\S]*?waitForRemoteConnection\(ref\.hostId\)[\s\S]*?app\.OpenRemoteWorkspace\(ref\.hostId, ref\.workspace\)/.test(remoteSource),
"in-app tabs use the remote-session bridge while the browser surface reconnects first",
);
ok(
/app\.RemoveRemoteProject\(ref\.hostId, ref\.workspace\)/.test(remoteSource) && /void refresh\(\);/.test(remoteSource),
"unpin removes the registration and refreshes the tree",
);
ok(
/remoteServeBadgeState\(remoteServers\[node\.remote\.hostId\]\?\.\[node\.remote\.workspace\], remoteGroupBusy/.test(source),
"the group row badge reflects the workspace-specific serve state",
);
ok(
/sessionLoads\.current\.has\(key\)/.test(remoteSource) &&
/sessionLoadGenerations\.current\.set\(key, load\)/.test(remoteSource) &&
/eligibleSessionKeys\.current\.has\(key\)/.test(remoteSource) &&
/filter\(\(\[key\]\) => retained\.has\(key\)\)/.test(remoteSource) &&
/acceptRemoteSessionRows\(key, rows\)/.test(remoteSource) &&
/catch\(\(error\) => \{[\s\S]*?sessionLoadGenerations\.current\.get\(key\) === load && eligibleSessionKeys\.current\.has\(key\)[\s\S]*?recordRemoteSessionLoadError\(key, error\)/.test(remoteSource) &&
/recordRemoteSessionLoadError[\s\S]*?setGroupError/.test(remoteSource) &&
/setGroupError\(\(current\) => current\[key\] \? \{ \.\.\.current, \[key\]: "" \} : current\)/.test(remoteSource),
"session fetches fence stale results, retain rows on passive failures, and clear recovered group errors",
);
ok(
/catch \(error\) \{[\s\S]*?recordRemoteSessionLoadError\(key, error\)/.test(explicitEnsureSource) &&
!/removeRemoteSessionCache\(key\)/.test(explicitEnsureSource) &&
!/setSessions\(/.test(explicitEnsureSource),
"an explicit session refresh preserves the last successful rows and cache when Serve fails",
);
ok(
/useComposerModeActions\(\{[\s\S]*?remote: remoteSurfaceActive/.test(appSource) &&
/if \(remote && activeTabId\)[\s\S]*?SetRemoteTabComposerProfile\(/.test(modeActionsSource),
"remote composer mode changes publish all axes through one remote transaction",
);
ok(
/tab\.id === tabId && tab\.remote[\s\S]*?SetRemoteTabGoal\(tabId, trimmed\)/.test(appSource) &&
/onSend=\{remoteSurfaceActive \? remoteComposerSend : handleSend\}/.test(appSource),
"remote goal activation and goal-draft submission stay on the remote controller",
);
ok(
/remoteRuntimeCommand\(trimmed\)[\s\S]*?command\?\.method === "setModel"[\s\S]*?session\[command\.method\]\(command\.value\)[\s\S]*?await send/.test(remoteIntegrationSource) &&
/\^\\\/\(model\|effort\)/.test(remoteIntegrationSource),
"remote model and effort slash commands bypass optimistic conversational submit",
);
ok(
/trimmed === "\/new"[\s\S]*?method: "newSession"/.test(remoteIntegrationSource) &&
/trimmed === "\/clear"[\s\S]*?method: "clearSession"/.test(remoteIntegrationSource) &&
/command\?\.method === "clearSession"[\s\S]*?requestClear\(\)/.test(remoteIntegrationSource) &&
/command\?\.method === "newSession"[\s\S]*?openRemoteNewSession\(activeRemote, session\.retryHydration\)/.test(remoteIntegrationSource),
"remote clear and new commands bypass optimistic submit and use session rotation",
);
ok(
/verb === "compact"[\s\S]*?method: "compact"/.test(remoteIntegrationSource) &&
/const management = new Set\(\[[\s\S]*?"context"[\s\S]*?"goal"[\s\S]*?"mcp"/.test(remoteIntegrationSource) &&
/command\?\.method === "runManagementCommand"[\s\S]*?session\.runManagementCommand\(trimmed, command\.rehydrate\)/.test(remoteIntegrationSource) &&
/command\?\.method === "compact"[\s\S]*?session\.compact\(command\.value\)/.test(remoteIntegrationSource) &&
/verb === "goal" && remoteGoalCommandStartsTurn\(trimmed\)/.test(remoteIntegrationSource) &&
/rehydrate: verb === "branch" \|\| verb === "switch" \|\| verb === "rewind"/.test(remoteIntegrationSource),
"remote non-turn management commands bypass optimistic conversational submit",
);
ok(
/if \(activeTab\?\.remote\) return openRemoteNewSession\(activeTab\.remote, remoteSession\.retryHydration\)/.test(appSource),
"global New Session routes the active remote tab through its Serve controller",
);
ok(
/item\.id !== "cmd-terminal" && item\.id !== "cmd-reload-runtime"/.test(appSource),
"remote command palettes hide local-only terminal and runtime reload actions",
);
ok(
/const remote = index\.get\(topicId\);[\s\S]*?if \(!remote\) return false;[\s\S]*?await action\(remote\);/.test(remoteSource) &&
!/if \(remote\.name\) await action\(remote\)/.test(remoteSource),
"synthetic blank remote sessions still invoke rename, pin, and delete mutations",
);
ok(
/onRemoteTabUpdated\(\(meta\)/.test(remoteSource) &&
/RemoteProjectSessions\(meta\.remote\.hostId, meta\.remote\.workspace\)/.test(remoteSource),
"remote tab metadata updates refresh the affected session group",
);
ok(
/attachmentInputEnabled=\{!remoteSurfaceActive\}/.test(appSource) &&
/if \(!attachmentInputEnabled\) return;/.test(composerSource) &&
/disabled=\{!attachmentInputEnabled\}/.test(composerSource) &&
/attachmentInputEnabled \?/.test(contentMenuSource),
"remote composer disables local attachment input and native file paths",
);
ok(
/localDurableGuidance=\{!remoteSurfaceActive\}/.test(appSource) &&
/if \(!localDurableGuidance && onSteer\)[\s\S]*?await onSteer\(guidanceSubmitText, submitTabId\)/.test(composerSource) &&
/app\.SteerRemoteTab\(sourceTabId, text\.trim\(\)\)/.test(appSource),
"running remote guidance uses the Serve inbox instead of the local durable inbox",
);
ok(
/remoteSurfaceActive \? remoteSession\.transcript\.items : state\.items/.test(appSource) &&
/sessionItemsToMarkdown\(sessionTitle, exportItems, exportLive\)/.test(appSource),
"remote exports use the visible remote transcript",
);
ok(
/const visibleRuntimeState = remoteSurfaceActive \? remoteSession\.transcript : state/.test(appSource) &&
/tabId=\{remoteSurfaceActive \? undefined : activeTabId\}/.test(appSource) &&
/onCancelJob=\{remoteSurfaceActive \? remoteSession\.cancelJob : cancelJob\}/.test(appSource) &&
/backgroundRuntimes=\{remoteSurfaceActive \? \[\] : backgroundRuntimes\}/.test(appSource),
"remote status and context chrome never fall back to local session telemetry",
);
ok(
/turnPhase=\{visibleRuntimeState\.turnPhase\}/.test(appSource) &&
/turnStartAt=\{visibleRuntimeState\.turnStartAt\}/.test(appSource) &&
/liveStore=\{remoteSurfaceActive \? remoteSession\.liveStore : liveStore\}/.test(appSource) &&
/goalRuntime=\{remoteSurfaceActive \? remoteSession\.goalRuntime : state\.meta\?\.goalRuntime\}/.test(appSource) &&
/context=\{visibleRuntimeState\.context\}/.test(appSource),
"remote composer timing, tokens, live stream, and cost use the visible remote runtime",
);
ok(
/localWorkspaceDockBlocked = remoteSurfaceActive && \(rightDockMode === "files" \|\| rightDockMode === "changed"\)/.test(appSource) &&
/surfaceWorkspacePanelRenderable = effectiveWorkspacePanelRenderable && !localWorkspaceDockBlocked/.test(appSource) &&
/\{surfaceWorkspacePanelRenderable && \([\s\S]*?<WorkspacePanel/.test(appSource),
"remote surfaces do not mount the local Files or Changes workspace panel",
);
ok(
/session\.pauseGoal\(\)/.test(remoteIntegrationSource) && /session\.resumeGoal\(\)/.test(remoteIntegrationSource),
"remote Goal pause and resume actions route to the remote session",
);
ok(
/for \(let i = visibleRuntimeState\.items\.length - 1/.test(appSource) &&
/!remoteSurfaceActive && activeTabId && todoBatch/.test(appSource),
"remote todo shelf projects the visible transcript without calling the local dismissal backend",
);
ok(
/if \(remoteSurfaceActive\) return;[\s\S]*?setTerminalPanelOpen/.test(appSource) &&
/!remoteSurfaceActive && terminalContentVisible/.test(appSource) &&
/disabled=\{!terminalEnabled\}/.test(topicbarMenuSource),
"remote surfaces disable terminal shortcuts, mounting, and menu actions",
);
ok(
/SetRemoteTabComposerProfile\(activeTabId, controllerMode, toolApprovalMode, ""\)/.test(modeActionsSource) &&
!/Promise\.allSettled\(\[[\s\S]*?SetRemoteTabGoal\(activeTabId, goal\)/.test(modeActionsSource),
"remote collaboration changes rely on the atomic backend transaction instead of tunnel rollback",
);
ok(
/EnsureRemoteProjectSessions\(hostId: string, workspace: string\): Promise<RemoteSessionView\[\]>;/.test(bridgeSource),
"the bridge exposes the explicit-intent ensure listing",
);
ok(
/app\.EnsureRemoteProjectSessions\(hostId, workspace\)/.test(remoteSource) &&
/if \(groupBusyRef\.current\.has\(key\)\) return;/.test(remoteSource) &&
/sessionLoadGenerations\.current\.set\(key, load\);[\s\S]*?EnsureRemoteProjectSessions\(hostId, workspace\)[\s\S]*?sessionLoadGenerations\.current\.get\(key\) !== load/.test(remoteSource),
"a group click cold-starts the serve through the deduped, generation-fenced ensure listing",
);
ok(
/if \(node\.remote && willExpand\) \{\s*void ensureRemoteGroupSessions\(node\.remote\.hostId, node\.remote\.workspace\);/.test(source),
"an explicit expand refreshes remote rows even when an optimistic cache exists",
);
ok(
/activeRemoteProjectAncestorKeys\(treeWithRemoteSessions, activeRemote, projectNodeKey\)/.test(source) &&
/defaultExpandedProjectTreeKeys\(treeWithRemoteSessions, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath\)/.test(source),
"active remote ancestors come from the merged tree and host-qualified group identity",
);
ok(
/markActive\(treeWithRemoteSessions\)/.test(source) &&
/markNodeRead, treeWithRemoteSessions\]\);/.test(source) &&
!/markActive\(tree\);/.test(source),
"active synthesized remote rows are marked read when the merged session tree arrives",
);
ok(
/project-tree__remote-status/.test(remoteSource) && /projectTree\.remoteConnectFailed/.test(remoteSource),
"remote groups render retryable connect/error rows instead of going silent",
);
ok(
/existing\.selectionRevision\+\+[\s\S]*?a\.goRemoteTabSafe\("remoteTabResume"[\s\S]*?restoreRejectedRemoteTabOpenSelection/.test(remoteOpenSource),
"session switches resume in the background behind a generation guard",
);
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,96 @@
// Run: tsx src/__tests__/remote-running-reconcile.test.ts
//
// The remote session reuses this reducer; these cases pin the reconciliation
// contract that keeps the remote pill from spinning forever:
// - a failed submit rolls the optimistic running flag back (send_failed)
// - losing the serve connection mid-turn stops the pill (turn_interrupted)
// - provider_unreachable surfaces as one self-replacing notice and clears
// on the next turn
import { initialState, reducer } from "../lib/useController";
import type { WireEvent } from "../lib/types";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (a === b) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
const submit = { type: "user" as const, text: "你好", seq: 1, submissionId: "remote-1" };
const event = (e: Partial<WireEvent> & { kind: WireEvent["kind"] }) =>
reducer(initialState, { type: "event", e: e as WireEvent });
// --- a failed submit rolls running back and marks the bubble failed ---
{
const started = reducer(initialState, submit);
eq(started.running, true, "optimistic submit sets running");
const rolled = reducer(started, { type: "send_failed", submissionId: "remote-1", error: "Send failed: refused" });
eq(rolled.running, false, "send_failed clears running");
eq(rolled.turnActive, false, "send_failed clears turnActive");
eq(rolled.items.some((it) => it.kind === "user" && it.failed), true, "send_failed marks the bubble failed");
eq(rolled.items.some((it) => it.kind === "notice"), true, "send_failed adds a notice");
// A stale send_failed for another submission must not clobber a live turn.
const live = reducer(started, { type: "event", e: { kind: "turn_started" } as WireEvent });
const untouched = reducer(live, { type: "send_failed", submissionId: "remote-999", error: "x" });
eq(untouched.running, true, "stale send_failed leaves a live turn running");
}
// --- losing the connection mid-turn stops the pill ---
{
const running = event({ kind: "turn_started" });
eq(running.running, true, "turn_started sets running");
const interrupted = reducer(running, { type: "turn_interrupted" });
eq(interrupted.running, false, "turn_interrupted clears running");
eq(interrupted.turnActive, false, "turn_interrupted clears turnActive");
eq(interrupted.cancellable, false, "turn_interrupted clears cancellable");
eq(interrupted.items.some((it) => it.kind === "notice"), true, "turn_interrupted adds a notice");
// Idempotent on an idle transcript: no phantom notice.
const idle = reducer(initialState, { type: "turn_interrupted" });
eq(idle.items.length, initialState.items.length, "turn_interrupted is a no-op when idle");
}
// --- provider_unreachable: one self-replacing notice, cleared next turn ---
{
const running = event({ kind: "turn_started" });
const first = event({ kind: "provider_unreachable", text: "dial tcp 127.0.0.1:38211: connect: connection refused" }) as typeof running;
const withNotice = reducer(running, { type: "event", e: { kind: "provider_unreachable", text: "dial tcp 127.0.0.1:38211: connect: connection refused", retryAttempt: 1, retryMax: 10 } as WireEvent });
const count = withNotice.items.filter((it) => it.id === "provider-unreachable").length;
eq(count, 1, "provider_unreachable adds exactly one notice");
const again = reducer(withNotice, { type: "event", e: { kind: "provider_unreachable", text: "still down", retryAttempt: 2, retryMax: 10 } as WireEvent });
eq(again.items.filter((it) => it.id === "provider-unreachable").length, 1, "repeat provider_unreachable replaces the notice");
const nextTurn = reducer(again, { type: "event", e: { kind: "turn_started" } as WireEvent });
eq(nextTurn.items.some((it) => it.id === "provider-unreachable"), false, "next turn clears the channel notice");
void first;
}
// --- remote /status reconciliation maps onto backend_status ---
{
const running = event({ kind: "turn_started" });
const idleNow = reducer(running, {
type: "backend_status",
running: false,
cancellable: false,
snapshotAt: Date.now(),
});
eq(idleNow.running, false, "idle /status snapshot clears running");
eq(idleNow.turnActive, false, "idle /status snapshot clears turnActive");
const busyAgain = reducer(idleNow, {
type: "backend_status",
running: true,
snapshotAt: Date.now(),
});
eq(busyAgain.running, true, "running /status snapshot restores running");
}
if (failed > 0) {
process.stdout.write(`\n${failed} FAILED, ${passed} passed\n`);
process.exit(1);
}
process.stdout.write(`\n${passed} passed, 0 failed\n`);
@@ -1,7 +1,7 @@
// Run: tsx src/__tests__/remote-secret-dialog.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { JSDOM } from "jsdom";
import { act } from "react";
import type { AppBindings } from "../lib/bridge";
@@ -4,8 +4,10 @@
// "Open Remote Web" entry, Serve progress states, the workspace home-directory
// fallback, and the fixed remote-provider hint.
import { JSDOM } from "jsdom";
import React from "react";
import { JSDOM } from "jsdom";
import type { AppBindings } from "../lib/bridge";
let passed = 0;
let failed = 0;
@@ -48,12 +50,18 @@ onRemoteServer((s) => useRemoteStore.getState().setServer(s));
onRemoteStatus((s) => useRemoteStore.getState().applyStatus(s));
const openCalls: Array<{ hostId: string; workspace: string }> = [];
const stopCalls: Array<{ hostId: string; workspace: string }> = [];
const statusCalls: Array<{ hostId: string; workspace: string }> = [];
window.go = { main: { App: {
async RemoteLastWorkspace(hostId: string) {
return hostId === "box" ? "/srv/app" : "";
},
async RemoteServerStatus() {
return { hostId: "box", workspace: "/srv/app", state: "stopped" };
async RemoteServerStatus(hostId: string, workspace: string) {
statusCalls.push({ hostId, workspace });
// Echo the registered per-workspace view when one exists (the bound call
// returns the tracked entry), else a fresh stopped view.
return useRemoteStore.getState().servers[hostId]?.[workspace]
?? { hostId, workspace, state: "stopped" };
},
async RemoteServerLogs() {
return "";
@@ -61,10 +69,12 @@ window.go = { main: { App: {
async OpenRemoteWorkspace(hostId: string, workspace: string) {
openCalls.push({ hostId, workspace });
},
async StopRemoteServer() {},
} } };
async StopRemoteServer(hostId: string, workspace: string) {
stopCalls.push({ hostId, workspace });
},
} as Partial<AppBindings> as AppBindings } };
const host = { id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", useSSHConfig: false };
const host = { id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false };
useRemoteStore.getState().setHosts([host]);
useRemoteStore.getState().openExplorer("box");
useRemoteStore.getState().setExplorerTab("server");
@@ -124,7 +134,7 @@ await act(async () => {
useRemoteStore.getState().setExplorerTab("server");
useRemoteStore.getState().setHosts([
host,
{ id: "bare", label: "bare", host: "bare.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", useSSHConfig: false },
{ id: "bare", label: "bare", host: "bare.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
]);
useRemoteStore.getState().applyStatus({ hostId: "bare", state: "connected" });
await Promise.resolve();
@@ -142,6 +152,53 @@ ok(
`fresh host opens the SSH login home (got ${JSON.stringify(openCalls)})`,
);
// ── Per-workspace management: two registered serves on one host ──
await act(async () => {
useRemoteStore.getState().openExplorer("box");
useRemoteStore.getState().setExplorerTab("server");
await Promise.resolve();
});
await act(async () => {
__emitMockRemote("server", { hostId: "box", workspace: "/srv/app", state: "ready" });
__emitMockRemote("server", { hostId: "box", workspace: "/srv/web", state: "ready" });
await Promise.resolve();
});
const stopButton = () => Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.includes("Stop"));
ok(statusCalls.some((c) => c.hostId === "box" && c.workspace === "/srv/app"), "status is fetched per workspace (the input's workspace)");
await act(async () => {
stopButton()?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
ok(
stopCalls.length === 1 && stopCalls[0].hostId === "box" && stopCalls[0].workspace === "/srv/app",
`stop targets the managed workspace (got ${JSON.stringify(stopCalls)})`,
);
// Switching the input switches which registered serve the panel manages; the
// other workspace's entry is untouched in the store.
{
const wsInput = document.querySelector<HTMLInputElement>('input[placeholder="~"]');
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
await act(async () => {
setter?.call(wsInput, "/srv/web");
wsInput?.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
await Promise.resolve();
});
ok(stopButton()?.hasAttribute("disabled") === false, "switching the workspace keeps the other ready serve manageable");
await act(async () => {
stopButton()?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
ok(
stopCalls.length === 2 && stopCalls[1].workspace === "/srv/web",
`stop follows the workspace switch (got ${JSON.stringify(stopCalls)})`,
);
const servers = useRemoteStore.getState().servers;
ok(
servers.box?.["/srv/app"]?.state === "ready" && servers.box?.["/srv/web"]?.state === "ready",
"both workspaces keep independent entries in the store",
);
}
await act(async () => { root.unmount(); });
dom.window.close();
@@ -0,0 +1,65 @@
// Run: tsx src/__tests__/remote-session-cache.test.ts
import { JSDOM } from "jsdom";
import {
loadRemoteSessionCache,
REMOTE_SESSION_CACHE_TTL_MS,
removeRemoteSessionCache,
saveRemoteSessionCache,
} from "../lib/remoteSessionCache";
import type { RemoteSessionView } from "../lib/types";
let passed = 0;
function ok(value: boolean, label: string): void {
if (!value) throw new Error(label);
passed += 1;
process.stdout.write(` PASS ${label}\n`);
}
const dom = new JSDOM("<!doctype html>", { url: "http://localhost/" });
globalThis.localStorage = dom.window.localStorage;
const key = "box\u0000~/app";
const rows: RemoteSessionView[] = [{ name: "one", path: "/sessions/one.jsonl", title: "One", turns: 2 }];
const now = 10_000;
saveRemoteSessionCache(key, rows, now);
ok(loadRemoteSessionCache(key, now + 1)[0]?.name === "one", "restores a current versioned snapshot");
saveRemoteSessionCache(key, [
{ name: "", path: "/sessions/transient-blank.jsonl", title: "", turns: 0, current: true },
...rows,
], now);
const withoutBlank = loadRemoteSessionCache(key, now + 1);
ok(withoutBlank.length === 1 && withoutBlank[0]?.name === "one",
"does not persist a transient blank session with a generated path");
ok(loadRemoteSessionCache(key, now + REMOTE_SESSION_CACHE_TTL_MS + 1).length === 0, "expires stale snapshots");
ok(localStorage.length === 0, "removes an expired snapshot from storage");
localStorage.setItem("projectTree:remoteSessions:" + key, JSON.stringify(rows));
ok(loadRemoteSessionCache(key, now).length === 0, "invalidates the legacy unversioned array schema");
ok(localStorage.length === 0, "removes an invalid legacy snapshot");
localStorage.setItem("projectTree:remoteSessions:" + key, JSON.stringify({
version: 1,
savedAt: now,
rows: [{ name: "bad", title: "Bad", turns: 1, running: "yes" }],
}));
ok(loadRemoteSessionCache(key, now).length === 0, "rejects malformed optional row fields");
ok(localStorage.length === 0, "removes a malformed versioned snapshot");
localStorage.setItem("projectTree:remoteSessions:" + key, JSON.stringify({
version: 1,
savedAt: now,
rows: [{ name: "empty" }],
}));
const empty = loadRemoteSessionCache(key, now);
ok(empty.length === 1 && empty[0]?.title === "" && empty[0]?.turns === 0,
"normalizes Go-omitted zero-value title and turns");
saveRemoteSessionCache(key, rows, now);
removeRemoteSessionCache(key);
ok(loadRemoteSessionCache(key, now).length === 0, "explicit removal clears an unpinned project cache");
console.log(`\n${passed} remote session cache assertions passed.`);
@@ -0,0 +1,800 @@
// Run: tsx src/__tests__/remote-session-surface.test.tsx
import React from "react";
import { JSDOM } from "jsdom";
import { act } from "react";
import type { AppBindings } from "../lib/bridge";
import type { TabMeta } from "../lib/types";
import type { RemoteSessionApi } from "../lib/useRemoteSession";
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("\nRemote session surface + hook");
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.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window) as typeof getComputedStyle;
const elementProto = dom.window.HTMLElement.prototype;
Object.defineProperty(elementProto, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(elementProto, "offsetHeight", {
configurable: true,
get(this: HTMLElement) { return this.classList.contains("transcript") ? 800 : 40; },
});
Object.defineProperty(elementProto, "offsetWidth", { configurable: true, get: () => 800 });
Object.defineProperty(elementProto, "clientHeight", {
configurable: true,
get(this: HTMLElement) { return this.classList.contains("transcript") ? 800 : 40; },
});
Object.defineProperty(elementProto, "clientWidth", { configurable: true, get: () => 800 });
(elementProto as unknown as { scrollTo: (arg?: number | ScrollToOptions) => void }).scrollTo = function (
this: HTMLElement,
arg?: number | ScrollToOptions,
) {
this.scrollTop = typeof arg === "number" ? arg : arg?.top ?? this.scrollTop;
};
// Transcript's virtualization calls the global rAF; jsdom only exposes it on
// the (visual) window.
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
Object.defineProperty(elementProto, "detachEvent", { configurable: true, value: () => {} });
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
const tape: string[] = [];
let failApproval = false;
let failOpen = false;
let failHydration = true;
let statusGoalStatus: "stopped" | "complete" = "stopped";
let statusQualityFloor: "standard" | "delivery" = "standard";
let statusModelLabel = "DeepSeek · Mock";
let statusEffort = "high";
let statusPendingPrompt = false, replayedPrompts: unknown[] = [];
let snapshotHistory: unknown[] = [];
let blockApproval = false;
let releaseApproval: (() => void) | undefined;
let blockAnswer = false;
let releaseAnswer: (() => void) | undefined;
let resolveRaceSnapshot: ((value: { history: unknown[]; status: unknown }) => void) | undefined;
const resolveStateRaceSnapshots: Array<(value: { history: unknown[]; status: unknown }) => void> = [];
let rotationSnapshotCalls = 0;
let resolveRotationReconcile: ((value: { history: unknown[]; status: unknown }) => void) | undefined;
window.go = { main: { App: {
async RemoteTabSnapshot(tabId: string) {
tape.push(`snapshot:${tabId}`);
if (tabId === "tab-hydration-failure" && failHydration) throw new Error("history exceeds bridge limit");
if (tabId === "tab-pending-model") return new Promise(() => {});
if (tabId === "tab-race") return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveRaceSnapshot = resolve; });
if (tabId === "tab-state-race") return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveStateRaceSnapshots.push(resolve); });
if (tabId === "tab-reconcile-rotation") {
rotationSnapshotCalls += 1;
if (rotationSnapshotCalls === 2) {
return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveRotationReconcile = resolve; });
}
return {
history: [{ role: "assistant", content: rotationSnapshotCalls === 1 ? "initial session" : rotationSnapshotCalls === 3 ? "fresh rotated session" : "fresh reconciled turn" }],
status: { running: false, label: "Rotation", plan: false, toolApprovalMode: "ask", goal: "" },
};
}
if (tabId === "tab-tool-history") return {
history: [
{ role: "assistant", content: "", toolCalls: [{ id: "remote-tool", name: "bash", arguments: "{\"command\":\"go test ./...\"}" }] },
{ role: "tool", content: "remote tool output", toolCallId: "remote-tool", toolName: "bash" },
],
status: { running: false, label: "Tools", plan: false, toolApprovalMode: "ask", goal: "" },
};
if (tabId === "tab-replay") return {
history: [], status: { label: "Replay", plan: false, toolApprovalMode: "ask", goal: "" },
pendingEvents: [
{ kind: "approval_request", approval: { id: "replayed-approval", tool: "bash", subject: "pending while inactive" } },
{ kind: "extension_surface", extension: { pluginId: "replayed-plugin", surfaceId: "replayed-form", kind: "form", form: { title: "Pending form", fields: [{ key: "region", label: "Region", kind: "input" }] } } },
],
};
if (tabId === "tab-status-fallback") return { history: [] };
return {
history: snapshotHistory,
checkpoints: [{
turn: 3,
prompt: "checkpoint",
files: ["src/main.ts"],
fileCount: 2,
time: 1,
canCode: true,
canConversation: true,
}],
commands: [{ name: "remote-review", description: "Review remotely", kind: "custom", group: "skills" }],
status: {
label: statusModelLabel,
plan: true,
toolApprovalMode: "auto",
goal: "",
goalStatus: statusGoalStatus,
goalRuntime: { turnsUsed: 2, turnsLimit: 0, tokensUsed: 321, requestsUsed: 3, workDurationMs: 4000, tokensLimit: 0, noProgressTurns: 0, noProgressLimit: 0, budgetExtensions: 0 },
qualityFloor: statusQualityFloor,
effort: { supported: true, current: statusEffort, default: "auto", levels: ["auto", "high", "max"] },
used: 1200,
window: 64000,
cacheHit: 800,
cacheMiss: 400,
lastUsage: { promptTokens: 1000, completionTokens: 200, totalTokens: 1200, cacheHitTokens: 800, cacheMissTokens: 200 },
balance: { available: true, display: "¥88.00" },
sessionCostQuote: { original: { amount: "0.12", currency: "CNY" }, selected: { amount: "0.12", currency: "CNY" }, estimated: false, costComplete: true, displayComplete: true, complete: true },
jobs: [{ id: "job-remote", kind: "bash", label: "tests", status: "running", startedAt: 1 }],
},
};
},
async RemoteTabStatus(tabId: string) {
tape.push(`status:${tabId}`);
if (tabId === "tab-status-fallback") {
return { running: false, plan: true, toolApprovalMode: "yolo", goal: "", qualityFloor: "standard" };
}
return {
running: false, pendingPrompt: statusPendingPrompt, backgroundJobs: 0,
label: statusModelLabel, plan: true, toolApprovalMode: "auto", goal: "",
goalStatus: statusGoalStatus, qualityFloor: statusQualityFloor,
goalRuntime: { turnsUsed: 2, turnsLimit: 0, tokensUsed: 321, requestsUsed: 3, workDurationMs: 4000, tokensLimit: 0, noProgressTurns: 0, noProgressLimit: 0, budgetExtensions: 0 },
effort: { supported: true, current: statusEffort, default: "auto", levels: ["auto", "high", "max"] },
};
},
async ReplayRemoteTabPrompts(tabId: string) { tape.push(`replay-prompts:${tabId}`); return replayedPrompts; },
async SubmitRemoteTab(tabId: string, text: string) {
tape.push(`submit:${tabId}:${text}`);
},
async CancelRemoteTab(tabId: string) {
tape.push(`cancel:${tabId}`);
},
async RewindRemoteTab(tabId: string, checkpointId: string, scope: string) {
tape.push(`rewind:${tabId}:${checkpointId}:${scope}`);
},
async ForkRemoteTab(tabId: string, turn: number, name: string) {
tape.push(`fork:${tabId}:${turn}:${name}`);
},
async SummarizeRemoteTab(tabId: string, turn: number, mode: string) {
tape.push(`summarize:${tabId}:${turn}:${mode}`);
},
async CompactRemoteTab(tabId: string, instructions: string) { tape.push(`compact:${tabId}:${instructions}`); },
async SetRemoteTabEffort(tabId: string, level: string) {
tape.push(`effort:${tabId}:${level}`);
statusEffort = level;
},
async SetRemoteTabModel(tabId: string, ref: string) {
tape.push(`model:${tabId}:${ref}`);
statusModelLabel = `Model · ${ref}`;
statusEffort = "max";
},
async SetRemoteTabQualityFloor(tabId: string, floor: string) {
tape.push(`quality-floor:${tabId}:${floor}`);
statusQualityFloor = floor === "delivery" ? "delivery" : "standard";
},
async PauseRemoteTabGoal(tabId: string) {
tape.push(`pause-goal:${tabId}`);
},
async ResumeRemoteTabGoal(tabId: string) {
tape.push(`resume-goal:${tabId}`);
},
async SteerRemoteTab(tabId: string, input: string) {
tape.push(`steer:${tabId}:${input}`);
},
async CancelRemoteTabJobs(tabId: string, jobIds: string[]) {
tape.push(`cancel-jobs:${tabId}:${jobIds.join(",")}`);
},
async ApproveRemoteTab(tabId: string, callId: string, decision: string) {
tape.push(`approve:${tabId}:${callId}:${decision}`);
if (failApproval) throw new Error("tunnel write failed");
if (blockApproval) await new Promise<void>((resolve) => { releaseApproval = resolve; });
},
async ResolveRemoteTabPlanDecision(tabId: string, callId: string, action: string, feedback: string) {
tape.push(`plan-decision:${tabId}:${callId}:${action}:${feedback}`);
},
async AnswerRemoteTab(tabId: string, callId: string, answers: Array<{ QuestionID: string; Selected: string[] }>) {
tape.push(`answer:${tabId}:${callId}:${JSON.stringify(answers)}`);
if (blockAnswer) await new Promise<void>((resolve) => { releaseAnswer = resolve; });
},
async SubmitRemoteTabExtensionForm(tabId: string, pluginId: string, surfaceId: string, values: Record<string, unknown>) {
tape.push(`extension-form:${tabId}:${pluginId}:${surfaceId}:${JSON.stringify(values)}`);
},
async OpenRemoteProjectTab(hostId: string, workspace: string, opts?: { newSession?: boolean }) {
tape.push(`open:${hostId}:${workspace}:${opts?.newSession ? "new" : ""}`);
if (failOpen) throw new Error("reconnect failed");
return { ...remoteTab, remote: { hostId, workspace } };
},
async SetActiveTab(tabID: string) {
tape.push(`setActive:${tabID}`);
},
} as Partial<AppBindings> as AppBindings } };
const [{ createRoot }, { RemoteSessionSurface }, { LocaleProvider }, { useRemoteSession }, { __emitMockRemoteTab }, { remoteRuntimeCommand }] = await Promise.all([
import("react-dom/client"),
import("../components/RemoteSessionSurface"),
import("../lib/i18n"),
import("../lib/useRemoteSession"),
import("../lib/bridge"),
import("../lib/useRemoteComposerIntegration"),
]);
const remoteTab: TabMeta = {
id: "tab-remote-1",
scope: "project",
workspaceRoot: "~/app",
workspaceName: "app",
topicId: "",
topicTitle: "app",
label: "gpu-box",
ready: true,
running: false,
mode: "normal",
active: true,
cwd: "~/app",
remote: { hostId: "gpu-box", workspace: "~/app" },
};
async function flush(ticks = 4) {
for (let i = 0; i < ticks; i++) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 40));
}
// The surface takes its session from the hook — the same wiring the app
// shell uses (the shared Transcript renders the content, the composer lives
// in the shell).
function RemoteSurfaceHarness({ tab }: { tab: TabMeta }) {
const session = useRemoteSession(tab.id);
return <RemoteSessionSurface tab={tab} session={session} />;
}
// ── Surface: shared Transcript renders reducer-driven items ──
const root = createRoot(document.getElementById("root")!);
await act(async () => {
root.render(
<LocaleProvider>
<RemoteSurfaceHarness tab={remoteTab} />
</LocaleProvider>,
);
});
await act(async () => flush());
ok(document.querySelector(".remote-surface__log") === null, "no bespoke log rows — the shared Transcript owns rendering");
ok(!document.querySelector(".remote-surface__composer"), "the surface renders no composer of its own");
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "turn_started" });
__emitMockRemoteTab("tab-remote-1", "event", { kind: "reasoning", reasoning: "thinking hard" });
__emitMockRemoteTab("tab-remote-1", "event", { kind: "text", text: "streaming answer" });
await flush();
});
ok(document.body.textContent?.includes("streaming answer") === true, "serve text frames render through the local transcript pipeline");
ok(document.body.textContent?.includes("thinking hard") === true, "reasoning renders through the local pipeline");
{
const snapshotsBefore = tape.filter((entry) => entry.startsWith("snapshot:")).length;
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "state", { state: "ready" });
await flush();
});
ok(tape.filter((entry) => entry.startsWith("snapshot:")).length > snapshotsBefore, "a ready transition re-syncs the snapshot (session reset / reconnect path)");
}
ok(!document.body.textContent?.includes("streaming answer"), "the re-synced snapshot replaces the old session content")
await act(async () => {
const statusBefore = tape.filter((entry) => entry === "status:tab-remote-1").length;
__emitMockRemoteTab("tab-remote-1", "event", { kind: "turn_done" });
await flush();
ok(tape.filter((entry) => entry === "status:tab-remote-1").length > statusBefore, "turn_done refreshes remote goal/runtime status");
});
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", approval: { id: "call-9", tool: "bash", subject: "rm -rf /tmp/junk" } });
await flush();
});
{
const dialog = document.querySelector(".remote-surface__approval");
ok(Boolean(dialog), "approval card renders");
ok(dialog?.textContent?.includes("rm -rf /tmp/junk") === true, "approval subject renders");
ok(dialog?.textContent?.includes("Allow matching for this session") === true
&& dialog?.textContent?.includes("Always allow matching operations") === true,
"remote approval exposes session and persistent scopes");
await act(async () => {
[...dialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")].find((b) => b.textContent?.includes("Allow matching for this session"))?.click();
await flush();
});
await act(async () => {
dialog?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
await new Promise((resolve) => setTimeout(resolve, 220));
});
ok(tape.includes("approve:tab-remote-1:call-9:session"), "session grant forwards its approval scope");
ok(!document.querySelector(".remote-surface__approval"), "approval card clears after deciding");
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", {
kind: "approval_request",
approval: { id: "plan-remote", tool: "exit_plan_mode", subject: "Plan ready" },
});
await flush();
});
{
const planDialog = document.querySelector(".remote-surface__approval");
await act(async () => {
[...planDialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")]
.find((button) => button.textContent?.includes("Revise plan"))?.click();
await flush();
});
await act(async () => {
const input = planDialog?.querySelector<HTMLTextAreaElement>(".plan-revision__input");
if (input) {
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, "value")?.set?.call(input, "cover the rollback path");
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
}
await flush();
});
await act(async () => {
planDialog?.querySelector<HTMLButtonElement>(".plan-revision__actions .btn--primary")?.click();
await new Promise((resolve) => setTimeout(resolve, 220));
});
ok(tape.includes("plan-decision:tab-remote-1:plan-remote:revise_plan:cover the rollback path"),
"remote plan revision uses the specialized decision endpoint and preserves feedback");
ok(!tape.some((entry) => entry.startsWith("approve:tab-remote-1:plan-remote")),
"remote plan decisions never collapse into generic approval booleans");
}
await act(async () => {
failApproval = true;
__emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", approval: { id: "call-fail", tool: "bash", subject: "keep this prompt" } });
await flush();
});
{
await act(async () => {
const failedDialog = document.querySelector(".remote-surface__approval");
[...failedDialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")].find((b) => b.textContent?.includes("Allow once"))?.click();
await flush();
});
await act(async () => {
const failedDialog = document.querySelector(".remote-surface__approval");
failedDialog?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
await new Promise((resolve) => setTimeout(resolve, 220));
await flush();
});
ok(Boolean(document.querySelector(".remote-surface__approval")), "a failed approval command preserves the decision card");
ok(document.body.textContent?.includes("tunnel write failed") === true, "a failed approval command surfaces an actionable error");
failApproval = false;
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", ask: { id: "ask-7", questions: [{ id: "q1", prompt: "Deploy now?", options: [{ label: "yes" }, { label: "no" }] }] } });
await flush();
});
{
const dialog = document.querySelector(".prompt-shelf--ask");
ok(Boolean(dialog), "ask card renders");
ok(dialog?.textContent?.includes("Deploy now?") === true, "ask prompt renders");
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "yes")?.click();
await flush();
});
ok(!tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-7")), "selecting an option keeps the ask open until explicit submit");
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "Submit")?.click();
await flush();
});
ok(tape.includes('answer:tab-remote-1:ask-7:[{"QuestionID":"q1","Selected":["yes"]}]'), "submit forwards the question id and complete selection batch");
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
await flush();
});
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Other answer")?.click();
await flush();
});
await act(async () => {
const input = document.querySelector<HTMLInputElement>(".ask-shelf__custom");
if (input) {
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set?.call(input, "canary");
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
}
await flush();
});
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Submit")?.click();
await flush();
});
ok(tape.includes('answer:tab-remote-1:ask-custom:[{"QuestionID":"q-custom","Selected":["canary"]}]'),
"custom AskCard text serializes as the remote question selection");
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", {
kind: "extension_surface",
extension: {
pluginId: "remote-plugin", surfaceId: "setup", kind: "form",
form: { title: "Remote setup", fields: [{ key: "region", label: "Region", kind: "input", required: true, default: "us-west" }] },
},
});
await flush();
});
{
const form = document.querySelector(".extension-form");
ok(Boolean(form) && document.body.textContent?.includes("Remote setup") === true, "remote extension form renders on the shared surface");
await act(async () => {
form?.closest(".prompt-shelf")?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
await flush();
});
ok(tape.includes('extension-form:tab-remote-1:remote-plugin:setup:{"region":"us-west"}'),
"remote extension form submits through the Serve proxy");
ok(!document.querySelector(".extension-form"), "remote extension form clears after an accepted submission");
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "state", { state: "serve_down", error: "tunnel closed" });
await flush();
});
{
const warning = document.querySelector(".remote-surface--warning");
ok(Boolean(warning), "serve_down renders the warning state");
ok(warning?.textContent?.includes("tunnel closed") === true, "serve error detail renders");
await act(async () => {
warning?.querySelector<HTMLButtonElement>("button")?.click();
await flush();
});
ok(tape.includes("open:gpu-box:~/app:"), "serve_down retry preserves the backend's parked session target");
failOpen = true;
await act(async () => {
warning?.querySelector<HTMLButtonElement>("button")?.click();
await flush();
});
ok(warning?.textContent?.includes("reconnect failed") === true, "serve_down retry failures render on the surface");
failOpen = false;
}
// Mid-flight disconnected events also refuse the placeholder.
await act(async () => { __emitMockRemoteTab("tab-remote-1", "state", { state: "disconnected" }); await flush(); });
{
ok(!document.querySelector(".remote-surface--disconnected"), "live disconnected events do not render the placeholder");
ok(Boolean(document.querySelector(".remote-surface--waiting")), "live disconnected events show connecting instead");
ok(tape.includes("setActive:tab-remote-1"), "live disconnected events trigger backend revival");
}
await act(async () => root.unmount());
let restoredShellProbe: RemoteSessionApi | undefined;
function RestoredShellProbe() { restoredShellProbe = useRemoteSession("tab-restored-shell", "disconnected"); return null; }
const restoredShellRoot = createRoot(document.getElementById("root")!);
await act(async () => { restoredShellRoot.render(<RestoredShellProbe />); await flush(); });
ok(restoredShellProbe?.state === "ready" && restoredShellProbe.hydrated, "restored shells recover through the authoritative snapshot when the ready event is missed");
ok(tape.includes("setActive:tab-restored-shell"), "restored disconnected shells trigger backend revival");
await act(async () => restoredShellRoot.unmount());
// ── Hook: optimistic user bubble + command forwarding ──
let probe: RemoteSessionApi | undefined;
function HookProbe({ tabId = "tab-remote-2" }: { tabId?: string }) { probe = useRemoteSession(tabId); return null; }
const probeRoot = createRoot(document.createElement("div"));
await act(async () => { probeRoot.render(<LocaleProvider><HookProbe /></LocaleProvider>); await flush(); });
ok(probe?.state === "ready", "a successful fenced snapshot recovers a ready event missed before listener mount");
ok(probe?.composerProfile?.collaborationMode === "plan" && probe?.composerProfile?.toolApprovalMode === "auto" && probe.goalRuntime?.tokensUsed === 321,
"snapshot status hydrates the authoritative remote composer profile");
ok(probe?.effort?.current === "high" && probe?.transcript.checkpoints[0]?.turn === 3
&& probe?.transcript.checkpoints[0]?.fileCount === 2 && probe?.transcript.checkpoints[0]?.files.length === 1,
"snapshot status hydrates effort and rewind checkpoints");
ok(probe?.commands.length === 1 && probe.commands[0]?.name === "remote-review",
"remote hydration exposes the Serve command catalog instead of local commands");
ok(probe?.transcript.context.used === 1200 && probe.transcript.context.window === 64000
&& probe.transcript.balance?.display === "¥88.00" && probe.transcript.sessionCost === 0.12
&& probe.transcript.jobs[0]?.id === "job-remote" && probe.transcript.lastTurnOutputTokens === 200,
"snapshot status hydrates remote context, usage, balance, cost, and jobs");
ok(remoteRuntimeCommand("/goal write regression tests") === undefined && remoteRuntimeCommand("/goal --strict write regression tests") === undefined, "goal-setting commands remain conversational turns");
ok(remoteRuntimeCommand("/goal")?.method === "runManagementCommand" && remoteRuntimeCommand("/goal status")?.method === "runManagementCommand"
&& remoteRuntimeCommand("/goal --strict pause")?.method === "runManagementCommand",
"goal status and lifecycle actions remain synchronous management commands");
ok(remoteRuntimeCommand("/branch experiment")?.rehydrate === true && remoteRuntimeCommand("/switch main")?.rehydrate === true
&& remoteRuntimeCommand("/rewind 3 conversation")?.rehydrate === true && remoteRuntimeCommand("/context")?.rehydrate !== true
&& remoteRuntimeCommand("/compact preserve tests")?.method === "compact",
"session-changing management commands request authoritative rehydration");
statusPendingPrompt = true; replayedPrompts = [{ kind: "approval_request", approval: { id: "recovered-prompt", tool: "bash", subject: "replayed after drop" } }];
await act(async () => { await probe?.runManagementCommand("/context"); await flush(); });
ok(tape.includes("replay-prompts:tab-remote-2") && probe?.transcript.approval?.id === "recovered-prompt", "pending status recovers an SSE-dropped prompt through Serve");
await act(async () => { await probe?.approve("recovered-prompt", "deny"); await flush(); }); statusPendingPrompt = false; replayedPrompts = [];
let remoteLiveNotifications = 0;
const unsubscribeRemoteLive = probe?.liveStore.subscribe("tab-remote-2", () => { remoteLiveNotifications += 1; });
await act(async () => {
__emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_started", turnStartedAt: 1234 });
__emitMockRemoteTab("tab-remote-2", "event", { kind: "text", text: "remote live ticker" });
await flush();
});
ok(probe?.liveStore.getSnapshot("tab-remote-2")?.text === "remote live ticker" && remoteLiveNotifications > 0,
"remote live-store notifications drive the shared composer ticker");
unsubscribeRemoteLive?.();
const turnDoneGeneration = probe?.surfaceGeneration;
statusGoalStatus = "complete";
snapshotHistory = [{ role: "user", content: "server-side prompt" }, { role: "assistant", content: "reconciled final answer" }];
await act(async () => {
__emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_done" });
await flush();
});
ok(probe?.composerProfile?.goalStatus === "complete" && probe.surfaceGeneration === turnDoneGeneration,
"turn_done refreshes goal status without replacing the transcript surface");
ok(probe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "reconciled final answer") === true,
"turn_done reconciles durable history after dropped Serve frames");
await act(async () => {
__emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-old", tool: "bash", subject: "old" } });
await flush();
});
blockApproval = true;
let oldApproval: Promise<void> | undefined;
await act(async () => {
oldApproval = probe?.approve("approval-old", "allow");
await flush();
__emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-next", tool: "bash", subject: "next" } });
releaseApproval?.();
await oldApproval;
await flush();
});
blockApproval = false;
ok(probe?.transcript.approval?.id === "approval-next", "an answered approval cannot clear the next prompt");
await act(async () => {
__emitMockRemoteTab("tab-remote-2", "event", { kind: "ask_request", ask: { id: "ask-old", questions: [] } });
await flush();
});
blockAnswer = true;
let oldAnswer: Promise<void> | undefined;
await act(async () => {
oldAnswer = probe?.answer("ask-old", []);
await flush();
__emitMockRemoteTab("tab-remote-2", "event", { kind: "ask_request", ask: { id: "ask-next", questions: [] } });
releaseAnswer?.();
await oldAnswer;
await flush();
});
blockAnswer = false;
ok(probe?.transcript.ask?.id === "ask-next", "an answered ask cannot clear the next prompt");
await act(async () => { await probe?.submit("run tests"); await flush(); });
ok(Boolean(probe?.transcript.items.some((item) => item.kind === "user" && item.text === "run tests")), "submit adds the optimistic user bubble through the shared reducer");
await act(async () => { await probe?.runManagementCommand("/context"); await flush(); });
ok(tape.includes("submit:tab-remote-2:/context") && tape.includes("status:tab-remote-2"),
"management commands dispatch without conversational admission and refresh status");
ok(!probe?.transcript.items.some((item) => item.kind === "user" && item.text === "/context"),
"management commands do not add an optimistic user turn");
const beforeCompactGeneration = probe?.surfaceGeneration;
snapshotHistory = [{ role: "assistant", content: "compacted remote history" }];
await act(async () => { await probe?.compact("preserve tests"); await flush(); });
ok(tape.includes("compact:tab-remote-2:preserve tests") && probe?.surfaceGeneration === (beforeCompactGeneration ?? 0) + 1 && probe.transcript.items.some((item) => item.kind === "assistant" && item.text === "compacted remote history"), "remote compact waits for the dedicated endpoint and rehydrates history");
const beforeSwitchGeneration = probe?.surfaceGeneration;
snapshotHistory = [{ role: "assistant", content: "adopted switched session" }];
await act(async () => { await probe?.runManagementCommand("/switch feature", true); await flush(); });
ok(tape.includes("submit:tab-remote-2:/switch feature") && probe?.surfaceGeneration === (beforeSwitchGeneration ?? 0) + 1
&& probe.transcript.items.some((item) => item.kind === "assistant" && item.text === "adopted switched session"),
"session-changing management commands replace history from an authoritative snapshot");
ok(!probe?.transcript.items.some((item) => item.kind === "user" && item.text === "/switch feature"),
"session-changing management commands still avoid an optimistic user turn");
await act(async () => {
await probe?.cancelTurn();
await probe?.approve("call-1", "allow");
await probe?.answer("ask-1", [{ QuestionID: "q1", Selected: ["yes"] }]);
await probe?.rewind(3, "code");
await probe?.rewind(3, "fork");
await probe?.rewind(3, "summ-from");
await probe?.rewind(3, "summ-upto");
await flush();
});
const metadataGeneration = probe?.surfaceGeneration;
statusGoalStatus = "complete";
await act(async () => {
await probe?.setModel("remote/new-model");
await probe?.setEffort("high");
await probe?.setQualityFloor("delivery");
await probe?.pauseGoal();
await probe?.resumeGoal();
await probe?.steer("narrow the change");
await probe?.cancelJob("job-remote");
await flush();
});
ok(probe?.surfaceGeneration === metadataGeneration, "metadata-only remote commands preserve the transcript generation and viewport");
ok(probe?.composerProfile?.goalStatus === "complete" && probe.composerProfile.qualityFloor === "delivery",
"status-only refresh updates goal status and quality floor");
ok(probe?.modelLabel === "Model · remote/new-model" && probe.effort?.current === "high",
"model switching refreshes the authoritative remote profile before the next turn");
for (const want of [
"submit:tab-remote-2:run tests",
"cancel:tab-remote-2",
"approve:tab-remote-2:call-1:allow",
'answer:tab-remote-2:ask-1:[{"QuestionID":"q1","Selected":["yes"]}]',
"rewind:tab-remote-2:3:code",
"fork:tab-remote-2:3:",
"summarize:tab-remote-2:3:from",
"summarize:tab-remote-2:3:upto",
"model:tab-remote-2:remote/new-model",
"effort:tab-remote-2:high",
"quality-floor:tab-remote-2:delivery",
"pause-goal:tab-remote-2",
"resume-goal:tab-remote-2",
"steer:tab-remote-2:narrow the change",
"cancel-jobs:tab-remote-2:job-remote",
]) {
ok(tape.includes(want), `command forwarded: ${want}`);
}
await act(async () => {
probeRoot.render(<LocaleProvider><HookProbe tabId="tab-pending-model" /></LocaleProvider>);
await Promise.resolve();
});
ok(probe?.modelLabel === "", "switching remote tabs clears the previous model label before hydration");
await act(async () => probeRoot.unmount());
let fallbackProbe: RemoteSessionApi | undefined;
function FallbackProbe() {
fallbackProbe = useRemoteSession("tab-status-fallback");
return null;
}
const fallbackRoot = createRoot(document.createElement("div"));
await act(async () => {
fallbackRoot.render(<LocaleProvider><FallbackProbe /></LocaleProvider>);
await flush();
});
ok(fallbackProbe?.hydrated === true && fallbackProbe.composerProfile?.collaborationMode === "plan"
&& fallbackProbe.composerProfile.toolApprovalMode === "yolo"
&& tape.includes("status:tab-status-fallback"),
"missing aggregate status is fetched before the remote composer becomes ready");
await act(async () => fallbackRoot.unmount());
let toolProbe: RemoteSessionApi | undefined;
function ToolProbe() {
toolProbe = useRemoteSession("tab-tool-history");
return null;
}
const toolRoot = createRoot(document.createElement("div"));
await act(async () => {
toolRoot.render(<LocaleProvider><ToolProbe /></LocaleProvider>);
await flush();
});
const remoteTool = toolProbe?.transcript.items.find((item) => item.kind === "tool" && item.id === "remote-tool");
ok(remoteTool?.kind === "tool" && remoteTool.args.includes("go test ./...")
&& remoteTool.output === "remote tool output" && remoteTool.dataArchived !== true,
"remote history retains expandable tool args and output without a local rehydrate endpoint");
await act(async () => toolRoot.unmount());
let failureProbe: RemoteSessionApi | undefined;
function FailureProbe() {
failureProbe = useRemoteSession("tab-hydration-failure", "ready");
return null;
}
const failureRoot = createRoot(document.createElement("div"));
await act(async () => {
failureRoot.render(<LocaleProvider><FailureProbe /></LocaleProvider>);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2200));
});
ok(failureProbe?.hydrated === false && failureProbe.error.includes("history exceeds bridge limit"),
"exhausted ready-session hydration exposes a retryable error");
failHydration = false;
await act(async () => {
await failureProbe?.retryHydration();
await flush();
});
ok(failureProbe?.hydrated === true && failureProbe.error === "", "explicit hydration retry recovers the surface");
await act(async () => failureRoot.unmount());
// ── Hydration fence: an SSE event delivered while the snapshot is in flight
// is replayed after history instead of being overwritten by it. ──
let raceProbe: RemoteSessionApi | undefined;
function RaceProbe() {
raceProbe = useRemoteSession("tab-race");
return null;
}
const raceRoot = createRoot(document.createElement("div"));
await act(async () => {
raceRoot.render(<LocaleProvider><RaceProbe /></LocaleProvider>);
await Promise.resolve();
});
await act(async () => {
__emitMockRemoteTab("tab-race", "event", { kind: "turn_started" });
__emitMockRemoteTab("tab-race", "event", { kind: "text", text: "arrived during hydration" });
resolveRaceSnapshot?.({
history: [],
status: { running: true, label: "Race", plan: false, toolApprovalMode: "ask", goal: "" },
});
await flush();
});
ok(raceProbe?.transcript.live.text === "arrived during hydration", "hydration replays concurrently delivered remote events");
await act(async () => raceRoot.unmount());
let stateRaceProbe: RemoteSessionApi | undefined;
function StateRaceProbe() {
stateRaceProbe = useRemoteSession("tab-state-race");
return null;
}
const stateRaceRoot = createRoot(document.createElement("div"));
await act(async () => {
stateRaceRoot.render(<LocaleProvider><StateRaceProbe /></LocaleProvider>);
await Promise.resolve();
});
await act(async () => {
__emitMockRemoteTab("tab-state-race", "state", { state: "reconnecting" });
__emitMockRemoteTab("tab-state-race", "state", { state: "ready" });
resolveStateRaceSnapshots[0]?.({
history: [],
status: { running: false, label: "Stale", plan: false, toolApprovalMode: "ask", goal: "" },
});
await flush();
});
await act(async () => {
resolveStateRaceSnapshots[1]?.({
history: [{ role: "assistant", content: "fresh generation" }],
status: { running: false, label: "Fresh", plan: false, toolApprovalMode: "ask", goal: "" },
});
await flush();
});
ok(stateRaceProbe?.state === "ready" && stateRaceProbe.hydrated === true
&& stateRaceProbe.modelLabel === "Fresh"
&& stateRaceProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh generation"),
"a ready generation re-hydrates after discarding the stale in-flight snapshot");
await act(async () => stateRaceRoot.unmount());
// Post-turn reconciliation can overlap a ready-to-ready /new, /clear, or
// resume. The old history response must not replace the newly adopted session.
let rotationProbe: RemoteSessionApi | undefined;
function RotationProbe() { rotationProbe = useRemoteSession("tab-reconcile-rotation"); return null; }
const rotationRoot = createRoot(document.createElement("div"));
await act(async () => { rotationRoot.render(<LocaleProvider><RotationProbe /></LocaleProvider>); await flush(); });
await act(async () => {
__emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" });
__emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" });
await Promise.resolve();
__emitMockRemoteTab("tab-reconcile-rotation", "state", { state: "ready" });
await flush();
});
ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh rotated session") === true,
"ready-to-ready rotation hydrates the adopted session while old reconciliation is pending");
await act(async () => { __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" }); __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" }); await Promise.resolve(); });
await act(async () => {
resolveRotationReconcile?.({
history: [{ role: "assistant", content: "stale previous session" }],
status: { running: false, label: "Stale", plan: false, toolApprovalMode: "ask", goal: "" },
});
await flush();
});
ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh reconciled turn") === true
&& !rotationProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "stale previous session"),
"session generation fence rejects stale history and hands reconciliation to the new generation");
await act(async () => rotationRoot.unmount());
// Pending prompt frames retained by Desktop are replayed by the next snapshot,
// which restores decisions missed while the tab had no frontend listener.
let replayProbe: RemoteSessionApi | undefined;
function ReplayProbe() { replayProbe = useRemoteSession("tab-replay"); return null; }
const replayRoot = createRoot(document.createElement("div"));
await act(async () => {
replayRoot.render(<LocaleProvider><ReplayProbe /></LocaleProvider>);
await flush();
});
ok(replayProbe?.transcript.approval?.id === "replayed-approval", "snapshot replays a prompt emitted while the remote tab was inactive");
ok(replayProbe?.transcript.extensionForm?.pluginId === "replayed-plugin" && replayProbe.transcript.extensionForm.surfaceId === "replayed-form", "snapshot replays an extension form emitted while the remote tab was inactive");
await act(async () => { replayProbe?.drainApprovals(["different-approval"]); await flush(); });
ok(replayProbe?.transcript.approval?.id === "replayed-approval", "a remote mode transaction preserves approvals it did not drain");
await act(async () => { replayProbe?.drainApprovals(["replayed-approval"]); await flush(); });
ok(replayProbe?.transcript.approval === undefined, "a remote mode transaction clears the exact approval it auto-allowed");
await act(async () => replayRoot.unmount());
dom.window.close();
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -26,7 +26,7 @@ function reset() {
}
useRemoteStore.getState().setHosts([
{ id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", useSSHConfig: false },
{ id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
]);
eq(useRemoteStore.getState().hosts[0]?.defaultWorkspace, "/srv/app", "configured hosts hydrate persistent UI state");
@@ -180,5 +180,19 @@ eq(useRemoteStore.getState().statuses.timeout?.state, "connecting", "connection
eq(received, null, "unsubscribe stops delivery");
})();
// setServer keys per-workspace entries under the host; one workspace's events
// never overwrite another's.
(function testPerWorkspaceServers() {
const set = useRemoteStore.getState().setServer;
set({ hostId: "box", workspace: "/srv/app", state: "ready" });
set({ hostId: "box", workspace: "/srv/web", state: "starting" });
const servers = useRemoteStore.getState().servers;
eq(servers.box?.["/srv/app"]?.state, "ready", "first workspace keeps its entry");
eq(servers.box?.["/srv/web"]?.state, "starting", "second workspace gets its own entry");
set({ hostId: "box", workspace: "/srv/web", state: "ready" });
eq(useRemoteStore.getState().servers.box?.["/srv/app"]?.state, "ready", "updating one workspace leaves the other untouched");
eq(useRemoteStore.getState().servers.box?.["/srv/web"]?.state, "ready", "updated workspace reflects the new state");
})();
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,165 @@
// Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-switch-from-local.test.tsx
//
// Local → remote activation must not run HistorySliceForTab and must not
// revert to the previous local tab when that local hydrate fails.
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { HistorySlice, HistorySliceRequest, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 40; attempt += 1) {
await act(async () => { await flushPromises(); });
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.HTMLElement = dom.window.HTMLElement;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
const localTab: TabMeta = {
id: "tab-local",
scope: "project",
workspaceRoot: "/repo/local",
workspaceName: "local",
workspacePath: "/repo/local",
topicId: "topic-local",
topicTitle: "local",
sessionPath: "/repo/local/sessions/local.jsonl",
label: "local-model",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo/local",
};
const remoteTab: TabMeta = {
id: "tab-remote",
scope: "project",
workspaceRoot: "~/app",
workspaceName: "app",
topicId: "",
topicTitle: "remote session",
label: "gpu-box",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: false,
cwd: "~/app",
remote: { hostId: "gpu-box", workspace: "~/app" },
remoteState: "ready",
};
let backendActiveId = "tab-local";
let historySliceCalls: string[] = [];
let setActiveCalls: string[] = [];
window.go = {
main: {
App: {
ListTabs: async () => {
const tabs = [localTab, remoteTab].map((tab) => ({ ...tab, active: tab.id === backendActiveId }));
return tabs;
},
SetActiveTab: async (tabID: string) => {
setActiveCalls.push(tabID);
backendActiveId = tabID;
},
HistorySliceForTab: async (tabID: string, _req: HistorySliceRequest): Promise<HistorySlice> => {
historySliceCalls.push(tabID);
if (tabID === "tab-remote") {
return {
messages: [],
startTurn: 0,
endTurn: 0,
hasOlder: false,
hasNewer: false,
error: "session path unavailable before controller ready",
};
}
return {
messages: [{ role: "user", content: "local hello" }],
startTurn: 1,
endTurn: 1,
hasOlder: false,
hasNewer: false,
};
},
MetaForTab: async () => ({ label: "local-model", ready: true, eventChannel: "agent:event", cwd: "/repo/local" }),
ContextUsageForTab: async () => ({ used: 0, window: 0 }),
EffortForTab: async () => ({ level: "high", supported: ["high"] }),
BalanceForTab: async () => ({ available: false, display: "" }),
JobsForTab: async () => [],
CheckpointsForTab: async () => [],
ReplayPendingPrompts: async () => {},
ReportUIReady: async () => {},
} as Partial<AppBindings> as AppBindings,
},
};
type ControllerApi = {
activeTabId?: string;
switchTab: (tabId: string, optimisticTab?: TabMeta) => Promise<unknown>;
};
let controller: ControllerApi | undefined;
function Probe() {
controller = useController() as unknown as ControllerApi;
return null;
}
const root = createRoot(document.getElementById("root")!);
await act(async () => { root.render(<Probe />); });
await waitFor("local active", () => controller?.activeTabId === "tab-local");
historySliceCalls = [];
setActiveCalls = [];
await act(async () => {
await controller?.switchTab(remoteTab.id, remoteTab);
await flushPromises();
});
await waitFor("remote stays active", () => controller?.activeTabId === "tab-remote");
eq(controller?.activeTabId, "tab-remote", "switching to a remote tab keeps the remote tab active");
ok(!historySliceCalls.includes("tab-remote"), "remote switch does not request local HistorySliceForTab");
ok(!setActiveCalls.includes("tab-local"), "failed local hydrate must not revert SetActiveTab back to the local tab");
eq(setActiveCalls[0], "tab-remote", "SetActiveTab is called for the remote tab");
await act(async () => { root.unmount(); });
dom.window.close();
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,129 @@
// Run: tsx src/__tests__/remote-tab-opened.test.tsx
import { JSDOM } from "jsdom";
import React, { act, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { __emitMockRemoteTab, __emitMockRemoteTabOpened, __emitMockRemoteTabUpdated, app } from "../lib/bridge";
import type { TabMeta } from "../lib/types";
import type { RemoteSessionApi } from "../lib/useRemoteSession";
import { useRemoteSession } from "../lib/useRemoteSession";
import { useRemoteTabOpened } from "../lib/useRemoteTabOpened";
import { useRemoteTabSwitch } from "../lib/useRemoteTabSwitch";
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("\nRemote tab opened/updated routing");
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;
const seeded: string[] = [];
const updated: string[] = [];
const switched: string[] = [];
const remoteMeta: TabMeta = {
id: "remote-1",
scope: "project",
workspaceRoot: "remote-project:host-a:/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
label: "model",
ready: true,
running: false,
active: false,
remote: { hostId: "host-a", workspace: "/repo" },
};
function Harness() {
const activeTabIdRef = useRef<string | undefined>("local-1");
useRemoteTabOpened(
activeTabIdRef,
(meta) => seeded.push(meta.id),
(meta) => updated.push(meta.id),
async (meta) => {
switched.push(meta.id);
},
);
return null;
}
const root = createRoot(document.getElementById("root")!);
await act(async () => root.render(<Harness />));
await act(async () => __emitMockRemoteTabOpened(remoteMeta));
eq(seeded.join(","), "remote-1", "opened events seed the new remote tab metadata");
eq(switched.join(","), "remote-1", "opened events activate through the dedicated remote switch");
await act(async () => __emitMockRemoteTabUpdated({ ...remoteMeta, topicTitle: "Background title" }));
eq(updated.join(","), "remote-1", "metadata updates patch the remote tab");
eq(switched.join(","), "remote-1", "metadata updates never steal focus");
await act(async () => root.unmount());
let directSwitch: ((meta: TabMeta) => Promise<void>) | undefined;
let historyCalls = 0;
let activeCalls = 0;
const originalHistory = app.HistorySliceForTab;
const previousGo = window.go;
window.go = { main: { App: {
HistorySliceForTab: async (...args: Parameters<typeof originalHistory>) => {
historyCalls += 1;
return originalHistory(...args);
},
SetActiveTab: async () => { activeCalls += 1; },
} as unknown as typeof app } } as typeof window.go;
function SwitchHarness() {
const [activeId, setActiveId] = useState<string | undefined>("local-1");
const activeIdRef = useRef<string | undefined>(activeId);
activeIdRef.current = activeId;
directSwitch = useRemoteTabSwitch({
activeTabIdRef: activeIdRef,
setActiveTabId: setActiveId,
beginNavigation: () => 1,
navigationCanComplete: () => true,
navigationIsCurrent: () => true,
confirmBackendActiveTab: () => undefined,
reassertVisibleTab: async () => undefined,
});
return <span data-active-id={activeId} />;
}
const switchRoot = createRoot(document.getElementById("root")!);
await act(async () => switchRoot.render(<SwitchHarness />));
await act(async () => directSwitch?.(remoteMeta));
eq(document.querySelector("span")?.getAttribute("data-active-id"), "remote-1", "remote activation updates the selected tab");
eq(activeCalls, 1, "remote activation still binds backend focus");
eq(historyCalls, 0, "remote activation bypasses local history hydration");
await act(async () => switchRoot.unmount());
let terminalProbe: RemoteSessionApi | undefined;
window.go = { main: { App: {
RemoteTabSnapshot: async () => { throw new Error("serve unavailable"); },
SetActiveTab: async (tabId: string) => {
__emitMockRemoteTab(tabId, "state", { state: "serve_down", error: "bootstrap failed" });
},
} as unknown as typeof app } } as typeof window.go;
function TerminalHarness() { terminalProbe = useRemoteSession("remote-terminal", "disconnected"); return null; }
const terminalRoot = createRoot(document.getElementById("root")!);
await act(async () => terminalRoot.render(<TerminalHarness />));
eq(terminalProbe?.state, "serve_down", "restored shell observes terminal state republished during activation");
await act(async () => terminalRoot.unmount());
window.go = previousGo;
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,27 @@
// Run: tsx src/__tests__/remote-topic-active.test.ts
import { topicIsActive } from "../components/ProjectTree";
import { mergeRemoteSessionsIntoTree } from "../components/ProjectTreeRemoteGroups";
import type { Translator } from "../lib/i18n";
import type { ProjectNode } from "../lib/types";
const remoteProject: ProjectNode = {
key: "project_remote_box_app", kind: "project", label: "app",
root: "remote-project:box:~/app", remote: { hostId: "box", workspace: "~/app" },
};
const remoteTopic = mergeRemoteSessionsIntoTree([remoteProject], {
"box\u0000~/app": [{ name: "s1", title: "First chat", turns: 1, path: "/remote/sessions/s1.jsonl" }],
}, ((key: string) => key) as Translator)[0]?.children?.[0];
if (!remoteTopic) throw new Error("remote topic was not synthesized");
function eq(actual: unknown, expected: unknown, label: string) {
if (actual !== expected) throw new Error(`${label}: expected ${String(expected)}, got ${String(actual)}`);
process.stdout.write(` PASS ${label}\n`);
}
eq(topicIsActive(remoteTopic, "project", "~/app", remoteTopic.topicId, remoteTopic.sessionPath), true,
"matching remote identity is active");
eq(topicIsActive(remoteTopic, "project", "~/app", "other-host\u0000~/app\u0000s1", remoteTopic.sessionPath), false,
"the same absolute path on another host stays inactive");
eq(topicIsActive(remoteTopic, "project", "~/app", "", remoteTopic.sessionPath), false,
"remote rows never use an unqualified path-only fallback");
@@ -30,7 +30,7 @@ function ok(value: boolean, label: string) {
}
}
console.log("\nsearch footnote render");
console.log("\nsearch sources panel render");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
@@ -64,7 +64,7 @@ await act(async () => {
text: "answer only",
reasoning: "",
streaming: false,
searchSources: [{ title: "新闻本文", url: "https://example.com/a" }],
searchSources: [{ title: "新闻本文", url: "https://example.com/a?utm_source=test#section" }],
}}
/>
</LocaleProvider>,
@@ -72,13 +72,21 @@ await act(async () => {
});
const body = document.querySelector(".msg__body");
const footnotes = document.querySelector(".msg-search-sources");
const title = footnotes?.querySelector("strong");
const link = footnotes?.querySelector("a");
const panel = document.querySelector(".msg-search-sources");
const toggle = panel?.querySelector<HTMLButtonElement>(".msg-search-sources__toggle");
ok(Boolean(body?.textContent?.includes("answer only")), "answer body keeps the model text");
ok(Boolean(footnotes), "footnotes render under the answer");
ok(title?.textContent === "新闻本文", "footnote shows the result title");
ok(Boolean(link), "footnote title list keeps a rendered link");
ok(Boolean(panel), "sources panel renders under the answer");
ok(toggle?.getAttribute("aria-expanded") === "false", "sources panel is collapsed by default");
ok(!panel?.querySelector(".msg-search-sources__body"), "collapsed panel does not expose source details");
await act(async () => {
toggle?.click();
});
const title = panel?.querySelector(".msg-search-source__title");
const link = panel?.querySelector<HTMLAnchorElement>(".msg-search-source__link");
ok(toggle?.getAttribute("aria-expanded") === "true", "click expands the sources panel");
ok(title?.textContent === "新闻本文", "expanded panel shows the result title");
ok(link?.getAttribute("href") === "https://example.com/a", "source link uses the cleaned URL");
ok(Boolean(panel?.querySelector(".msg-search-source__url")?.textContent?.includes("example.com/a")), "expanded panel shows the hostname and short URL");
ok(!(document.querySelector(".msg__body > .md")?.textContent ?? "").includes("新闻本文"), "answer markdown does not include the search title");
if (failed) {
@@ -6,6 +6,7 @@ import {
parseSearchSources,
searchSourcesFromHistory,
} from "../lib/searchSources";
import { normalizeSearchSources } from "../lib/searchSourcesPresentation";
import { historyMessagesToItems, initialState, reducer } from "../lib/useController";
import type { HistoryMessage, WireEvent } from "../lib/types";
@@ -40,6 +41,23 @@ eq(degraded[0]?.url, "https://example.com/a", "degraded dump unwraps the autolin
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 normalizedInput = [
{ title: "Tracked", url: "https://example.com/article?utm_source=search&gclid=abc&part=1#section" },
{ title: "Same canonical URL", url: "https://example.com/article?part=1" },
{ title: "Google redirect", url: "https://www.google.com/url?q=https%3A%2F%2Fdocs.example.com%2Fguide%3Futm_medium%3Dcpc%26x%3D1" },
{ title: "Broken redirect", url: "https://page.sm.cn/blm/midpage-317/index?id=11" },
{ title: "Unsafe", url: "javascript:alert(1)" },
{ title: " ", url: "https://empty-title.example" },
];
const normalized = normalizeSearchSources(normalizedInput);
eq(normalized.visible.length, 2, "normalization keeps only valid unique HTTP(S) sources");
eq(normalized.visible[0]?.href, "https://example.com/article?part=1", "tracking parameters and fragments are removed");
eq(normalized.visible[1]?.href, "https://docs.example.com/guide?x=1", "google redirect target is unwrapped and cleaned");
eq(normalized.visible[0]?.hostname, "example.com", "source projection exposes the hostname");
eq(normalized.visible[0]?.displayUrl, "example.com/article?part=1", "source projection exposes a compact URL");
eq(normalized.hiddenCount, 4, "invalid, duplicate, and missing-title sources are counted as hidden");
eq(normalizedInput[0]?.url, "https://example.com/article?utm_source=search&gclid=abc&part=1#section", "normalization does not mutate raw search data");
const history = historyMessagesToItems([{
role: "assistant",
content: "answer only",
@@ -3,7 +3,7 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { acceptsRuntimeEventEpoch, historyMessagesToItems, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
import { acceptsRuntimeEventEpoch, historyMessagesToItems, initialState, isLocalRuntimeCommand, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
import { continueDelivery } from "../lib/deliveryContinue";
import {
activateGoalAndSubmit,
@@ -115,6 +115,9 @@ eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", r
eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "failed", epoch: "e1" } }), false, "failed runtime cannot submit");
eq(runtimeReadyForSubmit({ label: "", ready: true, eventChannel: "", cwd: "", runtime: { phase: "ready", epoch: "e1" } }), true, "ready runtime can submit");
eq(normalizeTurnSubmit(" visible prompt ", " provider prompt ").submit, "provider prompt", "submit normalization trims provider input");
eq(isLocalRuntimeCommand(" /reload "), true, "/reload remains a host-only command without a turn receipt");
eq(isLocalRuntimeCommand("/effort max"), true, "/effort remains a host-only command without a turn receipt");
eq(isLocalRuntimeCommand("/reload now"), false, "non-command /reload text still starts an agent turn");
let rejectedVisibleOnlySubmit = false;
try {
normalizeTurnSubmit("visible prompt", " ");
@@ -181,11 +184,11 @@ const readinessNotice = readinessState.items[readinessState.items.length - 1];
eq(readinessNotice.kind, "notice", "final readiness appends a notice");
eq(readinessNotice.kind === "notice" && readinessNotice.level, "info", "final readiness uses informational severity");
eq(readinessNotice.kind === "notice" && readinessNotice.variant, "delivery", "final readiness uses the delivery status treatment");
eq(readinessNotice.kind === "notice" && readinessNotice.title, "The assistant replied, but task completion checks have not passed", "final readiness distinguishes assistant prose from host-owned completion state");
eq(readinessNotice.kind === "notice" && readinessNotice.title, "Delivery checks are not complete", "final readiness uses the explicit Delivery recovery title");
eq(
readinessNotice.kind === "notice" && readinessNotice.text,
"The response above is assistant-generated text, not the system check result. Reasonix still found required completion steps that are not complete; expand Details to see the exact gaps.",
"final readiness explains why a completed-sounding answer can still be gated",
"The response was generated, but verification and review still need to be completed.",
"final readiness explains the explicit Delivery recovery boundary",
);
eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: verification, change review", "structured requirements produce localized detail");
eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
@@ -283,7 +283,7 @@ await act(async () => {
let customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
if (!customCompactInput) throw new Error("custom compaction threshold input did not open");
eq(customCompactInput.value, "80", "custom compaction threshold defaults older backends to 80 percent");
ok(compactRootEl.textContent?.includes("65%") === true, "custom compact ratio explains the lower guard rail");
ok(compactRootEl.textContent?.includes("30%") === true, "custom compact ratio explains the lower guard rail");
ok(compactRootEl.textContent?.includes("85%") === true, "custom compact ratio explains the upper guard rail");
ok(document.activeElement === customCompactInput, "opening the custom compact ratio moves focus to its input");
ok(customCompactButton.getAttribute("aria-expanded") === "true", "custom compact ratio exposes its expanded state");
@@ -299,7 +299,7 @@ const setCustomCompactInput = (input: HTMLInputElement, value: string) => {
input.dispatchEvent(new Event("change", { bubbles: true }));
};
await act(async () => {
setCustomCompactInput(customCompactInput, "64");
setCustomCompactInput(customCompactInput, "29");
await flushPromises();
});
ok(customCompactApply.disabled, "out-of-range custom compact ratio cannot be applied");
@@ -317,8 +317,8 @@ ok(
"browser mock exposes the Token Rhythm preset",
);
ok(
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "deepseek-responses"\) return -2;/.test(bridgeSource),
"browser mock keeps the visible DeepSeek Responses preset first",
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "opencode-go-recommended"\) return -3;\s*if \(id === "deepseek-responses"\) return -2;/.test(bridgeSource),
"browser mock keeps OpenCode Go recommended first and DeepSeek Responses next",
);
const values = new Map<string, string>();
@@ -58,7 +58,7 @@ console.log("\nstatus bar workspace");
{
const remoteHosts = [
{ id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", useSSHConfig: false },
{ id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
];
const stopped = renderStatusBar({ workspacePath: "/workspace/repo", workspaceName: "repo", remoteHosts });
ok(stopped.includes("SSH · Disconnected"), "disconnected SSH entry keeps its full accessible status");
@@ -144,6 +144,7 @@ const tabK = tabMeta("tab-k", { sessionRevision: 1, sessionDigest: "digest-k-v1"
const tabL = tabMeta("tab-l", { sessionRevision: 1, sessionDigest: "digest-l-v1" });
const tabM = tabMeta("tab-m", { sessionRevision: 2, sessionDigest: "digest-m-v2" });
const tabN = tabMeta("tab-n", { sessionRevision: 2, sessionDigest: "digest-n-v2" });
const tabO = tabMeta("tab-o");
let backendActiveId = "tab-a";
const historyB = deferred<HistoryMessage[]>();
const historyD = deferred<HistoryMessage[]>();
@@ -187,7 +188,7 @@ let staleForkStarted = false;
let holdStaleForkReassertG = false;
let staleForkReassertGStarted = false;
const runningTabs = new Set<string>();
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI, tabK, tabL, tabM, tabN].map((tab) => [tab.id, tab]));
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI, tabK, tabL, tabM, tabN, tabO].map((tab) => [tab.id, tab]));
const eventHandlers: Array<(e: WireEvent) => void> = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
const topicActivationHandlers: Array<(e: TopicActivationEvent) => void> = [];
@@ -240,6 +241,7 @@ window.go = {
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async (tabID: string) => {
historyCalls.push(tabID);
if (tabID === "tab-o") { failSetActiveFor = "tab-a"; throw new Error("history failed at /private/session.jsonl"); }
if (tabID === "tab-b") return historyB.promise;
if (tabID === "tab-d") return historyD.promise;
if (tabID === "tab-e") return [userMessage("fork E")];
@@ -466,7 +468,6 @@ eq(newSessionTargets.join(","), "tab-b", "newSession keeps the selected tab as i
await act(async () => {
setActiveBGate.resolve();
await switchToB;
await newSessionWhileSwitching;
await flushPromises();
});
@@ -483,7 +484,7 @@ eq(historyCalls.length, historyCallsBeforeReturnToA, "cached idle tab skips hist
await act(async () => {
historyB.resolve([userMessage("late B")]);
await historyB.promise;
await Promise.all([historyB.promise, switchToB]);
await flushPromises();
});
@@ -598,6 +599,14 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
failSetActiveFor = "";
await act(async () => { await controller?.switchTab("tab-o", tabO); await flushPromises(); });
await waitFor("failed history and failed source restore settle on target", () => controller?.activeTabId === "tab-o" && Boolean(controller.state.hydrateError));
eq(controller?.activeTabId, "tab-o", "failed source rebind does not falsely restore the frontend source");
eq(backendActiveId, "tab-o", "failed source rebind keeps frontend and backend on the same target");
failSetActiveFor = "";
await act(async () => { await controller?.switchTab("tab-a", tabA); await flushPromises(); });
await waitFor("tab-a restored after failed source rebind", () => controller?.activeTabId === "tab-a");
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
@@ -785,6 +794,7 @@ await waitFor("single-surface activation replaces visible tab", () =>
controller?.activeTabId === "tab-g" &&
(controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false)
);
await act(async () => { controller?.commitSingleSurfaceNavigation("tab-g"); await flushPromises(); });
await act(async () => {
metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
await metaH.promise;
@@ -812,8 +822,7 @@ await waitFor("reopened tab-h finishes after stale meta discard", () =>
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
);
// A stale repair is itself asynchronous. Force a third navigation to complete
// while that repair is pending and verify the repair follows the newest tab.
// A third navigation must remain authoritative while stale repair is pending.
holdStaleSwitchF = true;
holdStaleSwitchReassertG = true;
let threeWaySwitchToF: Promise<TabMeta[] | undefined> | undefined;
@@ -858,10 +867,7 @@ eq(controller?.activeTabId, "tab-h", "third navigation remains visible after sta
eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale fork repair");
runningTabs.delete("tab-j");
// A same-path session can advance while this tab is inactive (another runtime,
// crash recovery, or a completed turn). Matching only the path would reuse the
// old transcript and hide the newer durable suffix. The persisted fingerprint
// must force one fresh history read, then allow reuse once the cache is current.
// A changed durable fingerprint must defeat same-path cache reuse, then become reusable.
await act(async () => {
await controller?.openProjectTab(tabK.workspaceRoot, tabK.topicId || "");
await flushPromises();
@@ -891,9 +897,7 @@ await act(async () => {
await waitFor("matching fingerprint reuses tab history", () => controller?.activeTabId === "tab-k");
eq(historyCalls.length, historyCallsAfterFingerprintRefresh, "matching fingerprint reuses the hydrated transcript after navigation");
// Older-page hydration must use the same canonical fingerprint as the visible
// page. A session can advance while the tab is open; an older response must be
// discarded instead of being prepended to the newer transcript.
// An older response with a stale fingerprint must not prepend into the newer page.
await act(async () => {
await controller?.openProjectTab(tabL.workspaceRoot, tabL.topicId || "");
await flushPromises();
@@ -923,9 +927,7 @@ await verifyDeferredHistoryCloseRace({
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq, sessionPath: tabL.sessionPath,
});
// The backend transcript and sidecar are separate durable files. If a save
// advances between those reads, hydration must reconcile the pair instead of
// caching an older page under the newer metadata fingerprint.
// Transcript and sidecar reads must reconcile when a save advances between them.
await act(async () => {
await controller?.openProjectTab(tabM.workspaceRoot, tabM.topicId || "");
await flushPromises();
@@ -937,9 +939,7 @@ await waitFor("split transcript/meta read reconciles", () =>
eq(historyMCalls, 2, "mismatched page and metadata trigger one bounded history reload");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale M v1") ?? false), "reconciled hydration does not retain the stale page");
// A live turn may start after the first durable page returns but before the
// metadata fingerprint is sampled. That live state owns the transcript; the
// bounded durable reconciliation must stop instead of replacing it.
// A live turn that starts between durable page and metadata reads owns the transcript.
startTabNDuringMeta = true;
await act(async () => {
await controller?.openProjectTab(tabN.workspaceRoot, tabN.topicId || "");
@@ -204,7 +204,11 @@ const reboundTabO = { ...tabO, sessionGeneration: 2 };
tabsById.set("tab-o", reboundTabO);
generationTwoHistory = deferred<HistoryMessage[]>();
heldTabOHistory = generationTwoHistory.promise;
await act(async () => { await controller?.switchTab("tab-o", reboundTabO); await flushPromises(); });
let generationTwoSwitch: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
generationTwoSwitch = controller?.switchTab("tab-o", reboundTabO);
await flushPromises();
});
eq(controller?.activeTabId, "tab-o", "generation-rebound tab becomes the selected target");
eq(controller?.state.items.length, 0, "generation-rebound tab clears its prior session before history settles");
@@ -213,11 +217,19 @@ eq(controller?.state.hydrating, true, "generation-rebound tab remains in target
await act(async () => {
generationTwoHistory.reject(new Error("generation 2 history failed"));
await generationTwoHistory.promise.catch(() => undefined);
await Promise.all([generationTwoHistory.promise.catch(() => undefined), generationTwoSwitch]);
await flushPromises();
});
await waitFor("target history error", () => Boolean(controller?.state.hydrateError));
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 1") ?? false), "target history failure never restores the prior generation");
await waitFor("source restored after target history failure", () => controller?.activeTabId === "tab-a");
eq(backendActiveId, "tab-a", "target history failure rebinds backend focus to the retained source session");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history A") ?? false, "target history failure restores the retained source transcript");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 1") ?? false), "target history failure never restores the prior target generation");
await act(async () => {
await controller?.openProjectTab(reboundTabO.workspaceRoot, reboundTabO.topicId || "");
await flushPromises();
});
await waitFor("generation two retry", () => controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 2") ?? false);
// A mount/ready sync can start before a same-tab session rebind and resolve
// afterwards. Its tab id still matches, so the navigation generation — not the
@@ -238,8 +250,9 @@ tabsById.set("tab-o", reboundTabOGenerationThree);
backendActiveId = "tab-o";
const generationThreeHistory = deferred<HistoryMessage[]>();
heldTabOHistory = generationThreeHistory.promise;
let generationThreeNavigation: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
await controller?.openProjectTab(reboundTabOGenerationThree.workspaceRoot, reboundTabOGenerationThree.topicId || "");
generationThreeNavigation = controller?.openProjectTab(reboundTabOGenerationThree.workspaceRoot, reboundTabOGenerationThree.topicId || "");
await flushPromises();
});
eq(controller?.state.meta?.sessionGeneration, 3, "newer same-tab navigation installs generation three identity");
@@ -254,7 +267,7 @@ eq(controller?.state.hydrating, true, "stale same-tab sync cannot cancel generat
await act(async () => {
generationThreeHistory.resolve([userMessage("history O generation 3")]);
await Promise.all([generationThreeHistory.promise, staleSync]);
await Promise.all([generationThreeHistory.promise, generationThreeNavigation, staleSync]);
await flushPromises();
});
await waitFor("generation three history", () => controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 3") ?? false);
@@ -300,11 +300,24 @@ ok(styleProps.get("--theme-pane-task-card-pct") === "82%", "computes task card p
ok(styleProps.get("--theme-pane-session-hover-pct") === "76%", "computes home session-hover opacity tier");
ok(styleProps.get("--theme-pane-child-pct") === "80%", "computes home child opacity tier");
ok(styleProps.get("--theme-pane-interact-pct") === "90%", "computes home interaction opacity tier");
ok(styleProps.get("--theme-pane-overlay-pct") === "90%", "computes home operational overlay opacity tier");
ok(styleProps.get("--theme-pane-task-session-hover-pct") === "94%", "computes task session-hover opacity tier");
ok(styleProps.get("--theme-pane-task-child-pct") === "98%", "computes task child opacity tier");
ok(styleProps.get("--theme-pane-task-interact-pct") === "100%", "caps task interaction opacity tier");
ok(styleProps.get("--theme-pane-task-overlay-pct") === "100%", "caps task operational overlay opacity tier");
ok(attrs.get("data-theme-safe-area") === "right", "task background controls safe area");
for (const [paneOpacity, expected] of [[0, "40%"], [0.5, "90%"], [1, "100%"]] as const) {
const opacityDraft = draftPackView({
...twoSceneDraft,
background: { ...twoSceneDraft.background!, paneOpacity },
taskBackground: { ...twoSceneDraft.taskBackground!, paneOpacity },
});
applyThemePack(opacityDraft);
ok(styleProps.get("--theme-pane-overlay-pct") === expected, `home overlay follows pane opacity ${paneOpacity}`);
ok(styleProps.get("--theme-pane-task-overlay-pct") === expected, `task overlay follows pane opacity ${paneOpacity}`);
}
// Older shells and partial mocks can expose the independent task scene without
// the newly added paneOpacity field. It must inherit the home pane value rather
// than falling through clamp01(undefined)'s generic midpoint.
@@ -329,12 +342,19 @@ ok(
"--theme-pane-session-hover-pct",
"--theme-pane-child-pct",
"--theme-pane-interact-pct",
"--theme-pane-overlay-pct",
"--theme-pane-task-session-hover-pct",
"--theme-pane-task-child-pct",
"--theme-pane-task-interact-pct",
"--theme-pane-task-overlay-pct",
].every((property) => !styleProps.has(property)),
"clearing a pack removes every extended pane opacity tier",
);
applyThemePack(tokenOnlyPreview);
ok(!attrs.has("data-theme-has-bg"), "token-only themes keep operational overlays on the opaque base surface");
ok(!styleProps.has("--theme-pane-overlay-pct"), "token-only themes do not inject home overlay transparency");
ok(!styleProps.has("--theme-pane-task-overlay-pct"), "token-only themes do not inject task overlay transparency");
clearThemePack();
ok(styleText("reasonix-base-code-readability").includes("--code-add-bg:"), "applyTheme installs the base code readability stylesheet");
beginThemePreview(draft);
ok(attrs.get("data-theme-pack") === "preview-pack", "preview applies pack");
@@ -97,6 +97,8 @@ const searchHistory = historyMessagesToItems([{
eq(searchHistory[0]?.kind, "tool", "server search hydrates as a tool card before the answer");
eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].name : "", "web_search", "server search card uses web_search");
eq(searchHistory[0]?.kind === "tool" ? subjectOf("web_search", searchHistory[0].args) : "", "bitcoin", "search card subject is the query");
eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].searchSources?.length : 0, 1, "search card keeps structured sources for display");
eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].output : "", "新闻本文\nhttps://example.com/a", "search card preserves raw output for replay/history");
eq(searchHistory[1]?.kind, "assistant", "model text stays a separate assistant item");
eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].text : "", "answer only", "answer text does not include search results");
eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].searchSources?.[0]?.title : "", "新闻本文", "answer hydrates search footnotes");
@@ -146,6 +146,9 @@ const requestIdByTab = new Map<string, string>();
// When true, the mock emits starting+ready synchronously BEFORE returning the
// ticket (exercises the terminal-event stash path).
let eagerActivationEvents = false;
let failedHistoryTabId = "";
let failSetActiveTabId = "";
let restoredTabSeq = 0;
function emitActivation(event: TopicActivationEvent): void {
for (const handler of topicActivationHandlers) handler(event);
@@ -153,6 +156,7 @@ function emitActivation(event: TopicActivationEvent): void {
function historyFor(tabID: string): HistoryMessage[] {
if (tabID === "tab-r") return [{ role: "user", content: "帮我查询,现在我链接的 deep" }];
if (tabID.startsWith("tab-c-restored-")) return [{ role: "user", content: "history tab-c" }];
return [{ role: "user", content: `history ${tabID}` }];
}
@@ -181,7 +185,10 @@ window.go = {
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async (tabID: string) => historyFor(tabID),
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => historySliceFromMessages(tabID, historyFor(tabID), req),
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => {
if (tabID === failedHistoryTabId) throw new Error(`/private/${tabID}/history.jsonl could not be read`);
return historySliceFromMessages(tabID, historyFor(tabID), req);
},
HistoryCheckpointTurnsForTab: async () => [],
StartTopicActivation: async (req: TopicActivationRequest) => {
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === req.workspaceRoot && tab.topicId === req.topicId) ?? tabA;
@@ -195,8 +202,20 @@ window.go = {
return { requestId, tabId: target.id, meta: { ...target, active: true } };
},
SetActiveTab: async (tabID: string) => {
if (tabID === failSetActiveTabId) throw new Error("source tab was already pruned");
backendActiveId = tabID;
},
OpenTopicSession: async (_scope: string, _workspaceRoot: string, _topicID: string, sessionPath: string) => {
const source = Array.from(tabsById.values()).find((tab) => tab.sessionPath === sessionPath) ?? tabA;
const restored = tabMeta(`${source.id}-restored-${++restoredTabSeq}`, {
...source,
id: `${source.id}-restored-${restoredTabSeq}`,
active: true,
});
tabsById.set(restored.id, restored);
backendActiveId = restored.id;
return restored;
},
ReplayPendingPrompts: async () => {},
} as Partial<AppBindings> as AppBindings,
},
@@ -257,7 +276,7 @@ await act(async () => {
await waitFor("C hydrates on its ready", () => hasHistory("tab-c") && controller?.state.hydrating === false);
ok(!hasHistory("tab-b"), "only the last click's history is visible");
// ── terminal failed surfaces the hydrate-error UI ───────────────────────────
// ── terminal failure restores the committed source surface ─────────────────
await act(async () => {
await controller?.activateTopic("project", tabA.workspaceRoot, tabA.topicId ?? "");
await flushPromises();
@@ -267,8 +286,27 @@ await act(async () => {
emitActivation({ requestId: requestIdByTab.get("tab-a") ?? "", tabId: "tab-a", phase: "failed", error: "session failed to start" });
await flushPromises();
});
eq(controller?.state.hydrating, false, "failed activation stops the hydrating surface");
eq(controller?.state.hydrateError, "session failed to start", "failed activation surfaces the sanitized error");
eq(controller?.activeTabId, "tab-c", "failed activation restores the previously committed source tab");
eq(controller?.state.hydrating, false, "restored source is immediately usable");
ok(hasHistory("tab-c"), "failed activation retains the source transcript");
const failureNotice = controller?.state.items.findLast((item) => item.kind === "notice");
ok(Boolean(failureNotice && failureNotice.kind === "notice" && !failureNotice.text.includes("session failed to start")), "failure notice is sanitized before it reaches the restored source");
// ── activation succeeds but target history fails: source still wins ─────────
failedHistoryTabId = "tab-a";
failSetActiveTabId = "tab-c";
await act(async () => {
await controller?.activateTopic("project", tabA.workspaceRoot, tabA.topicId ?? "");
await flushPromises();
emitActivation({ requestId: requestIdByTab.get("tab-a") ?? "", tabId: "tab-a", phase: "ready" });
await flushPromises();
});
await waitFor("history failure rebinds the pruned committed source", () => controller?.activeTabId?.startsWith("tab-c-restored-") ?? false);
ok(hasHistory("tab-c"), "target history failure retains the source controller transcript");
const historyFailureNotice = controller?.state.items.findLast((item) => item.kind === "notice");
ok(!(historyFailureNotice?.kind === "notice" && historyFailureNotice.text.includes("/private/")), "target history failure does not expose a local path");
failedHistoryTabId = "";
failSetActiveTabId = "";
// ── a terminal event that beats the ticket is stashed and replayed ──────────
eagerActivationEvents = true;
@@ -15,9 +15,19 @@ const moreMenuSource = [
assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/);
const taskSummaryControlIndex = moreMenuSource.indexOf('t("summary.session")');
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={surfaceWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control");
assert.ok(workspaceToggleIndex >= 0, "topic bar keeps the right-edge workspace toggle");
assert.match(
appSource,
/const localWorkspaceDockBlocked = remoteSurfaceActive && \(rightDockMode === "files" \|\| rightDockMode === "changed"\);/,
"remote sessions block local Files and Changes surfaces",
);
assert.match(
appSource,
/const surfaceWorkspacePanelRenderable = effectiveWorkspacePanelRenderable && !localWorkspaceDockBlocked;/,
"the topic bar projects the workspace toggle through the active surface boundary",
);
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");
process.stdout.write("topicbar controls: 4 contracts passed\n");
@@ -0,0 +1,13 @@
// Content-free geometry distilled from the field diagnostics. The original
// exports are intentionally excluded: only anonymous turn windows required to
// reproduce the unloaded-question transaction remain here.
export const unloadedQuestionJumpReplay = {
targetTurn: 0,
requestedTurn: 1,
totalTurns: 994,
windows: [
{ firstTurn: 561, lastTurn: 994, hasOlderHistory: true, rowCount: 434 },
{ firstTurn: 148, lastTurn: 994, hasOlderHistory: true, rowCount: 847 },
{ firstTurn: 1, lastTurn: 994, hasOlderHistory: false, rowCount: 994 },
] as const,
} as const;
@@ -258,6 +258,11 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
),
);
});
// Virtuoso and the lazy Markdown/live-region surfaces can schedule a
// second commit after the initial act (especially on a loaded CI
// runner). Drain one extra frame so render() promises a settled DOM to
// callers that intentionally assert immediately after rendering.
await flush();
await flush();
},
flush,
@@ -269,6 +274,11 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
await act(async () => current?.unmount());
},
close: async () => {
// React.lazy Markdown chunks may resolve just after the last act() in a
// block. Let those requests settle before tearing down Vite's SSR
// module runner; otherwise the runner reports a transport disconnect
// even though every assertion completed.
await new Promise((resolve) => setTimeout(resolve, 100));
await server.close();
},
loadModule: <T,>(path: string) => server.ssrLoadModule(path) as Promise<T>,
@@ -0,0 +1,44 @@
// Run: tsx src/__tests__/transcript-geometry-environment.test.ts
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import {
applyTypographyPreferences,
createDefaultTypographyPreferences,
onTypographyPreferencesChange,
} from "../lib/typographyPreferences";
import { readTranscriptGeometryEnvironment } from "../lib/transcriptGeometryEnvironment";
const dom = new JSDOM("<!doctype html><html><body><div class='transcript'></div></body></html>", { url: "http://localhost" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window) as typeof getComputedStyle;
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
const transcript = document.querySelector<HTMLElement>(".transcript")!;
Object.defineProperty(transcript, "clientWidth", { configurable: true, value: 800 });
document.documentElement.style.setProperty("--maxw", "960px");
document.documentElement.style.setProperty("--transcript-inline-pad", "32px");
transcript.style.fontFamily = "Test Conversation";
transcript.style.fontSize = "14px";
transcript.style.lineHeight = "22px";
const environment = readTranscriptGeometryEnvironment(transcript);
assert.equal(environment.contentWidth, 736, "geometry uses the readable column after transcript padding");
assert.match(environment.typographySignature, /Test Conversation/);
const baseline = createDefaultTypographyPreferences();
applyTypographyPreferences(baseline);
let changes = 0;
const unsubscribe = onTypographyPreferencesChange(() => { changes += 1; });
applyTypographyPreferences(baseline);
assert.equal(changes, 0, "reapplying identical typography publishes no geometry churn");
const changed = createDefaultTypographyPreferences();
changed.conversation = { ...changed.conversation, followGlobal: false, fontSize: 18 };
applyTypographyPreferences(changed);
assert.equal(changes, 1, "a real typography change publishes one invalidation");
unsubscribe();
console.log("transcript geometry environment tests passed");
@@ -67,11 +67,12 @@ const keys = (rows: readonly { key: string }[]) => rows.map((row) => row.key).jo
{
const live: TranscriptLiveFlags = { id: "a2", hasAnswerText: false, hasReasoning: true };
const { models, rows } = rowsFor([user("u1"), assistant("a2", { streaming: true })], live, true);
check(keys(rows) === `${userRowKey("u1")},ph:a2,r:a2`, `running turn rows look as expected (got ${keys(rows)})`);
const processKey = models[0].segments[0].key;
check(keys(rows) === `${userRowKey("u1")},ph:${processKey},r:a2`, `running turn rows look as expected (got ${keys(rows)})`);
const split = splitTranscriptLiveRows(models, rows, "a2", true);
check(split.liveActive, "running turn marks the live region active");
check(keys(split.historyRows) === userRowKey("u1"), "the active turn's user message stays in history");
check(keys(split.liveRows) === "ph:a2,r:a2", "the active turn's process rows render in the live region");
check(keys(split.liveRows) === `ph:${processKey},r:a2`, "the active turn's process rows render in the live region");
}
// ── A later user message stays in visual order behind the streaming turn ─────
@@ -83,9 +84,10 @@ const keys = (rows: readonly { key: string }[]) => rows.map((row) => row.key).jo
true,
);
const split = splitTranscriptLiveRows(models, rows, "a1", true);
const processKey = models[0].segments[0].key;
check(keys(split.historyRows) === userRowKey("u1"), "only the streaming turn's user row stays in history");
check(
keys(split.liveRows) === `ph:a1,r:a1,a:a1,${userRowKey("u2")}`,
keys(split.liveRows) === `ph:${processKey},r:a1,a:a1,${userRowKey("u2")}`,
`rows after the live turn keep their order in the live region (got ${keys(split.liveRows)})`,
);
}
@@ -4,8 +4,8 @@
// - EAW-aware text estimation: full-width scripts wrap at roughly half the
// character count, so CJK rows must estimate higher than the old
// char-based formula; half-width text stays close to the old behavior.
// - Measured-size cache: exact rowKey hit beats kind median beats static
// prior; medians converge on recorded samples; clear() drops everything.
// - Measured-size cache: exact row hits stay logical-row scoped; answer rows
// may use a bounded same-layout prior for first-visit stability.
import {
eastAsianWidthColumns,
@@ -22,6 +22,7 @@ import {
type TranscriptRow,
} from "../lib/transcriptRows";
import { createTranscriptMeasuredSizes } from "../lib/transcriptMeasuredSizes";
import { transcriptRowLayoutVariant } from "../lib/transcriptRowGeometry";
import { initialState, reducer, type Item } from "../lib/useController";
let passed = 0;
@@ -106,25 +107,99 @@ function fakeRow(key: string, kind: TranscriptRow["kind"], text: string): Transc
}
function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, row: TranscriptRow, width?: number) {
return store.synthesize([row], width)[0];
return estimatesFor(store, [row], width)[0];
}
function estimatesFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, rows: readonly TranscriptRow[], width?: number) {
return store.synthesizeDetailed("test-session", rows, { contentWidth: width, typographySignature: "test-font" }).heightEstimates;
}
function recordRow(store: ReturnType<typeof createTranscriptMeasuredSizes>, row: TranscriptRow, height: number, width?: number) {
store.recordGeometry("test-session", {
rowKey: String(row.key),
kind: row.kind,
layoutVariant: transcriptRowLayoutVariant(row),
height,
environment: { contentWidth: width, typographySignature: "test-font" },
measurementVersion: transcriptRowMeasurementVersion(row),
staticEstimate: estimateTranscriptRowSize(row, width),
});
}
{
const store = createTranscriptMeasuredSizes();
const row = fakeRow("r1", "answer", "hello");
ok(estimateFor(store, row) === estimateTranscriptRowSize(row), "no data falls back to the static prior");
store.record("r1", "answer", 432, undefined, transcriptRowMeasurementVersion(row));
recordRow(store, row, 432);
eq(estimateFor(store, row), 432, "exact rowKey measurement wins");
store.record("r1", "answer", 0);
store.record("r1", "answer", Number.NaN);
recordRow(store, row, 0);
recordRow(store, row, Number.NaN);
eq(estimateFor(store, row), 432, "non-positive and NaN measurements are ignored");
}
// ── Geometry-contract cache ────────────────────────────────────────────────
{
const store = createTranscriptMeasuredSizes();
const collapsed = fakeRow("reasoning", "reasoning", "hidden body");
collapsed.layoutVariant = "reasoning-summary";
const expanded = { ...collapsed, layoutVariant: "reasoning-expanded" as const };
const environment = { contentWidth: 960, typographySignature: "font-a" };
store.recordGeometry("session-a", {
rowKey: "reasoning",
kind: "reasoning",
layoutVariant: "reasoning-expanded",
height: 1_200,
environment,
measurementVersion: transcriptRowMeasurementVersion(expanded),
staticEstimate: estimateTranscriptRowSize(expanded, environment.contentWidth),
});
const synthesized = store.synthesizeDetailed("session-a", [collapsed], environment);
eq(synthesized.heightEstimates[0], 64, "expanded reasoning measurement cannot poison collapsed geometry");
eq(synthesized.estimateSources[0], "static", "cross-state rejection reports the static source");
}
{
const store = createTranscriptMeasuredSizes();
const row = fakeRow("answer", "answer", "wrapped answer");
row.layoutVariant = "text-flow";
const environment = { contentWidth: 960, typographySignature: "font-a" };
store.recordGeometry("session-a", {
rowKey: "answer",
kind: "answer",
layoutVariant: "text-flow",
height: 320,
environment,
measurementVersion: transcriptRowMeasurementVersion(row),
staticEstimate: estimateTranscriptRowSize(row, environment.contentWidth),
});
eq(store.synthesizeDetailed("session-a", [row], environment).heightEstimates[0], 320, "exact geometry matches all cache dimensions");
ok(store.synthesizeDetailed("session-a", [row], { ...environment, contentWidth: 760 }).heightEstimates[0] !== 320, "content width rejects an exact sample");
ok(store.synthesizeDetailed("session-a", [row], { ...environment, typographySignature: "font-b" }).heightEstimates[0] !== 320, "typography rejects an exact sample");
}
{
const store = createTranscriptMeasuredSizes({ maxSessions: 2, maxRowsPerSession: 2 });
const rowA = fakeRow("a", "answer", "answer A");
rowA.layoutVariant = "text-flow";
const environment = { contentWidth: 960, typographySignature: "font-a" };
store.recordGeometry("session-a", {
rowKey: "a", kind: "answer", layoutVariant: "text-flow", height: 222,
environment, measurementVersion: transcriptRowMeasurementVersion(rowA), staticEstimate: estimateTranscriptRowSize(rowA, 960),
});
store.synthesizeDetailed("session-b", [], environment);
eq(store.synthesizeDetailed("session-a", [rowA], environment).heightEstimates[0], 222, "A to B to A restores safe geometry without a scroll position");
store.synthesizeDetailed("session-c", [], environment);
store.synthesizeDetailed("session-d", [], environment);
ok(store.synthesizeDetailed("session-a", [rowA], environment).heightEstimates[0] !== 222, "session LRU evicts beyond the configured cap");
ok(store.synthesizeDetailed("session-b", [rowA], environment).heightEstimates[0] !== 222, "session LRU remains bounded");
}
{
const store = createTranscriptMeasuredSizes();
const before = fakeRow("late", "answer", "preview");
const after = fakeRow("late", "answer", "resolved content");
store.record("late", "answer", 777, 960, transcriptRowMeasurementVersion(before));
recordRow(store, before, 777, 960);
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");
}
@@ -134,9 +209,9 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
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);
recordRow(store, changedBefore, 900, 960);
recordRow(store, untouched, 240, 960);
const estimates = estimatesFor(store, [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");
}
@@ -146,8 +221,8 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
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);
recordRow(store, staleBefore, 999, 960);
const estimates = estimatesFor(store, [stale, unseen], 960);
ok(estimates[1] !== 999, "a stale-version sample is excluded from the kind median");
}
@@ -158,7 +233,7 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
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",
models[0].segments[0].key,
{ open: true, userOverridden: true, running: false },
]]);
const options = {
@@ -193,7 +268,7 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
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);
recordRow(store, beforeRow, 88, 960);
const after = reducer(before, {
type: "history_items_patch",
patches: { [preview.id]: { ...preview, text: "resolved ".repeat(2_000) } },
@@ -214,11 +289,11 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
{
const store = createTranscriptMeasuredSizes();
store.record("a1", "answer", 100);
store.record("a2", "answer", 300);
store.record("a3", "answer", 200);
recordRow(store, fakeRow("a1", "answer", "first"), 100);
recordRow(store, fakeRow("a2", "answer", "second"), 300);
recordRow(store, fakeRow("a3", "answer", "third"), 200);
const unseen = fakeRow("a4", "answer", "some answer text that is long enough to matter");
eq(estimateFor(store, unseen), 200, "unseen row of a sampled kind uses the kind median");
eq(estimateFor(store, unseen), 200, "unseen answer rows use a same-layout bounded prior");
const toolRow = fakeRow("t1", "tool", "");
eq(estimateFor(store, toolRow), estimateTranscriptRowSize(toolRow), "unsampled kind still uses the static prior");
}
@@ -228,18 +303,18 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
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));
recordRow(store, first, 291, 960);
recordRow(store, first, 632, 960);
recordRow(store, second, 400, 960);
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");
eq(estimateFor(store, unseen, 960), 516, "answer prior uses one latest sample per logical row");
}
{
const store = createTranscriptMeasuredSizes();
const row = fakeRow("width-sensitive", "answer", "wrapped answer");
store.record("width-sensitive", "answer", 632, 960, transcriptRowMeasurementVersion(row));
recordRow(store, row, 632, 960);
eq(estimateFor(store, row, 960), 632, "an exact measurement is reused at the measured width");
ok(
estimateFor(store, row, 760) === estimateTranscriptRowSize(row),
@@ -250,20 +325,12 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
{
const store = createTranscriptMeasuredSizes();
const rows = [fakeRow("r1", "user", "hi"), fakeRow("r2", "answer", "hello there")];
store.record("r1", "user", 64, undefined, transcriptRowMeasurementVersion(rows[0]));
const estimates = store.synthesize(rows);
recordRow(store, rows[0], 64);
const estimates = estimatesFor(store, rows);
eq(estimates.length, 2, "synthesize stays aligned with the row array");
eq(estimates[0], 64, "synthesize uses the exact measurement where present");
ok(estimates[1] > 0, "synthesize falls back cleanly for unmeasured rows");
}
{
const store = createTranscriptMeasuredSizes();
store.record("r1", "answer", 500);
store.clear();
const row = fakeRow("r1", "answer", "hello");
ok(estimateFor(store, row) === estimateTranscriptRowSize(row), "clear() drops measurements and medians");
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import assert from "node:assert/strict";
const root = join(import.meta.dirname, "..", "..");
const arbiter = readFileSync(join(root, "src/lib/useTranscriptScrollArbiter.ts"), "utf8");
const styles = readFileSync(join(root, "src/styles.css"), "utf8");
assert.equal(arbiter.includes("manualMeasurementFreezeRef"), false, "manual transcript scrolling has no measurement freeze state");
assert.equal(arbiter.includes("upwardManualGesture"), false, "wheel/touch reading does not freeze recycled rows");
assert.equal(
styles.includes("\n height: max(0px, calc(var(--transcript-row-estimate) - 32px))"),
true,
"pending Markdown uses only its bounded parser fallback height",
);
process.stdout.write("transcript measurement contract passed\n");
@@ -3,10 +3,12 @@
import { deepEqual, equal } from "node:assert/strict";
import { JSDOM } from "jsdom";
import {
hasPendingTranscriptGeometry,
isNativeVerticalScrollbarPointer,
measureTranscriptVirtuosoItem,
} from "../lib/transcriptNativeScrollbar";
import { noteTranscriptRowMeasurement, setTranscriptScrollDiagnosticSink } from "../lib/transcriptScrollProbe";
import { advanceViewportPagePermit, grantsNativeScrollbarPagePermit } from "../lib/useTranscriptNavigationSurface";
let passed = 0;
function check(actual: unknown, expected: unknown, label: string) {
@@ -41,6 +43,17 @@ process.stdout.write("\ntranscript native scrollbar\n");
check(isNativeVerticalScrollbarPointer(transcript, { button: 0, clientX: 1095 }), true, "left-button in the right native gutter starts the lock");
check(isNativeVerticalScrollbarPointer(transcript, { button: 0, clientX: 1085 }), false, "left-button in chat content does not start the lock");
check(isNativeVerticalScrollbarPointer(transcript, { button: 1, clientX: 1095 }), false, "middle-button autoscroll is not classified as thumb dragging");
check(grantsNativeScrollbarPagePermit(400, 300, true, false), true, "an observed upward native drag grants one page permit");
check(grantsNativeScrollbarPagePermit(300, 200, true, true), false, "the same native drag cannot grant a second page permit");
check(grantsNativeScrollbarPagePermit(400, 300, false, false), false, "scroll movement without a native drag grants no permit");
let viewportPermit = advanceViewportPagePermit(0, 0);
check(viewportPermit, 1, "one upward gesture grants a viewport page permit");
viewportPermit = advanceViewportPagePermit(viewportPermit, 1);
check(viewportPermit, 2, "startReached consumes the granted viewport page permit");
check(advanceViewportPagePermit(viewportPermit, 0), 2, "wheel burst events cannot queue another page while the request is active");
viewportPermit = advanceViewportPagePermit(viewportPermit, 2);
check(advanceViewportPagePermit(viewportPermit, 1), 0, "request completion clears burst permits before prepend reaches the start");
check(advanceViewportPagePermit(advanceViewportPagePermit(viewportPermit, 0), 1), 2, "a later user gesture can request one new page");
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 600 });
check(isNativeVerticalScrollbarPointer(transcript, { button: 0, clientX: 1095 }), false, "an empty native gutter without overflow does not start the lock");
@@ -57,9 +70,26 @@ row.getBoundingClientRect = () => ({
toJSON: () => ({}),
});
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "ordinary wheel path keeps real dynamic measurement");
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 160, "native thumb drag keeps the existing Virtuoso size");
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 640, "completed rows keep their measured height during native thumb drag");
row.dataset.transcriptEstimate = "180";
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 640, "completed rows never regress to a logical estimate");
delete row.dataset.knownSize;
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 640, "completed rows do not fall back to an estimate when known size is absent");
row.dataset.knownSize = "160";
delete row.dataset.transcriptEstimate;
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "real measurement resumes after thumb release");
const pendingMarkdown = dom.window.document.createElement("div");
pendingMarkdown.dataset.transcriptGeometryPending = "true";
row.dataset.staticEstimate = "157";
row.appendChild(pendingMarkdown);
check(hasPendingTranscriptGeometry(row), true, "a lazy Markdown fallback marks transient row geometry");
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 160, "native thumb drag freezes only pending row geometry at its last measured size");
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 157, "pending Markdown keeps the state-aware initial seed");
pendingMarkdown.remove();
check(hasPendingTranscriptGeometry(row), false, "resolved Markdown releases transient geometry");
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "resolved Markdown resumes browser measurement");
const measurementEvents: Array<{ type: string; fields: Record<string, unknown> }> = [];
setTranscriptScrollDiagnosticSink((type, fields) => measurementEvents.push({ type, fields }));
noteTranscriptRowMeasurement(row, "offsetHeight", 640);
@@ -7,8 +7,11 @@
// actually mounted in the viewport, not just that a jump was dispatched.
import { createTranscriptHarness } from "./transcript-dom-harness";
import { unloadedQuestionJumpReplay } from "./transcript-diagnostic-replay.fixtures";
import { QUESTION_JUMP_MAX_MARKERS } from "../components/QuestionJumpBar";
import type { Item } from "../lib/useController";
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
import { settleQuestionJumpSurfaceState } from "../lib/useTranscriptQuestionNavigation";
import { act } from "react";
let passed = 0;
@@ -95,6 +98,13 @@ function rowOffsetOf(container: HTMLElement, descendant: HTMLElement): number |
console.log("\ntranscript question jump landing");
let newestSurface: { token: number } | null = { token: 3 };
newestSurface = settleQuestionJumpSurfaceState(newestSurface, 1, null);
newestSurface = settleQuestionJumpSurfaceState(newestSurface, 2, null);
ok(newestSurface?.token === 3, "A→B completions cannot release C's question-jump surface");
newestSurface = settleQuestionJumpSurfaceState(newestSurface, 3, null);
ok(newestSurface === null, "only C's own terminal releases the latest surface");
// ── Physical rail clicks land on the selected question ──────────────────────
// First, middle, and tail positions, with back-and-forth jumps in between:
// a stale firstItemIndex offset reappears whenever the viewport moves, so the
@@ -113,6 +123,9 @@ console.log("\ntranscript question jump landing");
const clientY = railClientY(targetIndex, 40);
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }));
if (targetIndex === targetIndices[0]) {
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "an already-loaded target uses the same masked surface transaction");
}
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY, detail: 1 }));
dispatchScroll(el);
});
@@ -238,26 +251,58 @@ console.log("\ntranscript question jump landing");
}
}
// ── A one-turn remainder auto-loads without restoring the old fold button ───
// ── Auto-fill runs only after a stable, genuinely short first page ──────────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
// Keep the viewport deliberately larger than every estimate Virtuoso may
// use before its first measured row. This exercises the auto-fill branch
// itself instead of depending on estimate tuning in the virtualizer.
const harness = await createTranscriptHarness({ viewportHeight: 100_000, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
let loads = 0;
await harness.render(historyTurns(2, 61), {
const triggers: string[] = [];
await harness.render(historyTurns(61, 61), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 2,
historyStartTurn: 61,
historyTotalTurns: 61,
surfaceCommitToken: "navigation-1-short-surface",
onSurfacePaintReady: () => {},
onLoadOlderHistory: async (_targetTurn, trigger) => {
loads += 1;
triggers.push(trigger ?? "");
return true;
},
});
await harness.waitFor(() => loads > 0, "a short stable first page to auto-fill");
ok(triggers[0] === "auto-fill", "short-page loading is labeled as auto-fill");
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();
}
}
// ── Initial Virtuoso startReached cannot cascade a full first page ──────────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
let loads = 0;
await harness.render(historyTurns(1, 120), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 1,
historyTotalTurns: 120,
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");
await harness.settle();
ok(loads === 0, "mounting a scrollable 120-turn page requests no older history without user intent");
} finally {
await harness.unmount();
await harness.close();
@@ -347,6 +392,174 @@ console.log("\ntranscript question jump landing");
}
}
// ── Field replay: 434 → 847 → 994 stays masked and lands once ──────────────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
const replay = unloadedQuestionJumpReplay;
const requestedTurns: Array<number | undefined> = [];
const writes: TranscriptScrollWriteRecord[] = [];
harness.dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => writes.push(write);
const resolveLoads: Array<(loaded: boolean) => void> = [];
const onLoadOlderHistory = (targetTurn?: number) => {
requestedTurns.push(targetTurn);
return new Promise<boolean>((resolve) => { resolveLoads.push(resolve); });
};
const renderWindow = async (index: number, loadingOlderHistory: boolean) => {
const historyWindow = replay.windows[index];
await harness.render(historyTurns(historyWindow.firstTurn, historyWindow.lastTurn), {
running: false,
questionNavigator: true,
hasOlderHistory: historyWindow.hasOlderHistory,
loadingOlderHistory,
historyStartTurn: historyWindow.firstTurn,
historyTotalTurns: replay.totalTurns,
onLoadOlderHistory,
});
ok(
Number(harness.scrollElement().dataset.transcriptRowCount) === historyWindow.rowCount,
`field replay commits its ${historyWindow.rowCount}-row history window`,
);
};
await renderWindow(0, false);
await harness.settle();
const jumpScroll = stubRailGeometry(harness.container, QUESTION_JUMP_MAX_MARKERS);
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
button: 0,
clientY: railClientY(replay.targetTurn, replay.totalTurns),
}));
});
await harness.waitFor(
() => requestedTurns.includes(replay.requestedTurn),
"the field replay target page request",
);
await harness.flush();
ok(requestedTurns.length === 1, "the pending-jump effect cannot duplicate the in-flight request");
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "the unloaded target is masked before history mutates");
ok(writes.filter((write) => write.owner === "jump").length === 0, "the 434-row window emits no intermediate jump");
await renderWindow(1, true);
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "the 847-row intermediate window remains masked");
ok(writes.filter((write) => write.owner === "jump").length === 0, "the intermediate prepend emits no target jump");
await act(async () => resolveLoads[0]?.(true));
await renderWindow(1, false);
await harness.waitFor(
() => requestedTurns.length === 2 && requestedTurns[1] === replay.requestedTurn,
"the second targeted page request",
);
await renderWindow(1, true);
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "the second request keeps the surface masked");
await act(async () => resolveLoads[1]?.(true));
await renderWindow(2, false);
await harness.waitFor(
() => !harness.container.querySelector("[data-question-jump-mask='true']"),
"the final question-jump paint commit",
);
const jumpWrites = writes.filter((write) => write.owner === "jump");
ok(requestedTurns.length === 2, "the replay performs only the required targeted requests");
ok(jumpWrites.length === 1, "the 434→847→994 replay emits exactly one final jump");
ok(Boolean(harness.container.querySelector("#question-anchor-history-u1")), "the final jump mounts the requested question");
} finally {
harness.dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
await harness.unmount();
await harness.close();
}
}
// ── A targeted jump waits behind an existing history request ───────────────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
let attempts = 0;
let resolveLoad: ((loaded: boolean) => void) | undefined;
const onLoadOlderHistory = () => {
attempts += 1;
return new Promise<boolean>((resolve) => { resolveLoad = resolve; });
};
const render = (loadingOlderHistory: boolean) => harness.render(historyTurns(81, 100), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
loadingOlderHistory,
historyStartTurn: 81,
historyTotalTurns: 100,
onLoadOlderHistory,
});
await render(true);
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.flush();
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "an unloaded jump waits behind an existing history request without dropping its mask");
ok(attempts === 0, "an existing history request prevents a duplicate targeted request");
await render(false);
await harness.waitFor(() => attempts === 1, "the queued targeted request after existing history settles");
await act(async () => resolveLoad?.(false));
await harness.waitFor(
() => !harness.container.querySelector("[data-question-jump-mask='true']"),
"the queued targeted request terminal",
);
} finally {
await harness.unmount();
await harness.close();
}
}
// ── A rejected targeted request cannot strand the opaque surface ───────────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
let attempts = 0;
await harness.render(historyTurns(81, 100), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 81,
historyTotalTurns: 100,
onLoadOlderHistory: async () => {
attempts += 1;
return false;
},
});
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(() => attempts === 1, "the rejected targeted request");
await harness.waitFor(
() => !harness.container.querySelector("[data-question-jump-mask='true']"),
"the rejected targeted request terminal",
);
ok(attempts === 1, "a rejected targeted request is not retried as a paging cascade");
ok(!harness.container.querySelector("[data-question-jump-mask='true']"), "a rejected request cannot leave permanent loading");
} 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 });
@@ -84,7 +84,9 @@ const virtuosoHandle = {
lastScrollByTop = options?.top ?? 0;
scrollElement.scrollTop += lastScrollByTop;
},
scrollTo: () => {},
scrollTo: (options?: { top?: number }) => {
scrollElement.scrollTop = options?.top ?? scrollElement.scrollTop;
},
scrollToIndex: () => {},
getState: () => {},
} as unknown as VirtuosoHandle;
@@ -211,6 +213,121 @@ await flushFrames();
check(scrollByCalls === 0 && scrollWrites.length === 0,
"selection ownership cancels a pending reader transaction");
// A question jump owns the whole masked paging/landing transaction, not only
// the final indexed write. Entering it must invalidate a queued reader
// correction, and a generic scrollend from the indexed placement must not
// release ownership before the matching paint terminal.
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());
await act(async () => arbiter?.onWheelIntent({
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 133.33,
target: scrollElement,
} as React.WheelEvent<HTMLElement>));
scrollExtent = 4_000;
scrollElement.scrollTop = 1_000;
rowElement.getBoundingClientRect = () => rectAt(1_020);
await act(async () => arbiter?.deliverScroll());
await act(async () => arbiter?.beginQuestionJump(77));
scrollWrites.length = 0;
scrollByCalls = 0;
scrollExtent = 5_000;
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
check(scrollWrites.length === 0 && scrollByCalls === 0,
"question-jump ownership cancels queued reader and tail writers");
await act(async () => arbiter?.finishProgrammaticScroll());
check(String(arbiter?.modeRef.current) === "restoring",
"generic scrollend cannot release a masked question jump");
await act(async () => arbiter?.finishQuestionJump(76));
check(String(arbiter?.modeRef.current) === "restoring",
"a stale question-jump terminal cannot release the current transaction");
await act(async () => arbiter?.finishQuestionJump(77));
check(String(arbiter?.modeRef.current) === "manual",
"the matching paint terminal releases question-jump ownership");
// A downward wheel at the physical bottom must not arm reader-extent
// recovery. A later extent rebound would otherwise snap the viewport upward
// and fight the user's wheel input.
await act(async () => arbiter?.reset());
scrollExtent = 2_000;
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
scrollElement.scrollTop = 1_275;
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: 120,
target: scrollElement,
} as React.WheelEvent<HTMLElement>));
scrollElement.scrollTop = 1_100;
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
check(scrollByCalls === 0 && scrollWrites.length === 0,
"near-bottom downward wheel does not arm the reader-extent guard");
// A replacement native-thumb gesture owns a new pointer transaction. A late
// pointerup from the displaced gesture must not release or demote it.
await act(async () => arbiter?.reset());
scrollExtent = 5_000;
Object.defineProperties(scrollElement, {
offsetWidth: { configurable: true, value: 800 },
clientWidth: { configurable: true, value: 780 },
clientLeft: { configurable: true, value: 0 },
});
scrollElement.getBoundingClientRect = () => rectAt(0);
scrollElement.scrollTop = 1_000;
const thumbPointer = (pointerId: number) => ({
button: 0,
clientX: 795,
nativeEvent: { button: 0, clientX: 795, pointerId },
}) as unknown as React.PointerEvent<HTMLElement>;
await act(async () => {
arbiter?.onPointerDownIntent(thumbPointer(1));
arbiter?.onPointerDownIntent(thumbPointer(2));
});
const staleRelease = new dom.window.Event("pointerup", { bubbles: true });
Object.defineProperty(staleRelease, "pointerId", { value: 1 });
await act(async () => dom.window.dispatchEvent(staleRelease));
check(scrollElement.dataset.nativeScrollbarDrag === "true",
"a stale pointerup cannot release the replacement native thumb");
check(String(arbiter?.modeRef.current) === "native-thumb",
"the replacement thumb retains explicit scroll ownership");
// A stationary release at the physical bottom is insufficient: the same
// pointer transaction must have observed forward native progress.
scrollElement.scrollTop = 4_275;
await act(async () => arbiter?.deliverScroll(scrollElement));
const activeRelease = new dom.window.Event("pointerup", { bubbles: true });
Object.defineProperty(activeRelease, "pointerId", { value: 2 });
await act(async () => dom.window.dispatchEvent(activeRelease));
check(String(arbiter?.modeRef.current) === "tail-follow",
"the active thumb may commit tail ownership after forward native progress");
await act(async () => arbiter?.onPointerDownIntent(thumbPointer(3)));
const stationaryRelease = new dom.window.Event("pointerup", { bubbles: true });
Object.defineProperty(stationaryRelease, "pointerId", { value: 3 });
await act(async () => dom.window.dispatchEvent(stationaryRelease));
check(String(arbiter?.modeRef.current) === "manual",
"a stationary thumb at the bottom cannot claim tail ownership");
await act(async () => arbiter?.onPointerDownIntent(thumbPointer(4)));
await act(async () => arbiter?.reset());
check(scrollElement.dataset.nativeScrollbarDrag === undefined,
"a generation reset clears the native-thumb DOM transaction");
check(String(arbiter?.modeRef.current) === "tail-follow",
"a generation reset cannot retain native-thumb ownership");
await act(async () => root.unmount());
dom.window.close();
@@ -6,9 +6,11 @@ import { createRoot } from "react-dom/client";
import type { StateSnapshot, VirtuosoHandle } from "react-virtuoso";
import { useTranscriptScrollArbiter, type TranscriptRecoveryTerminal } from "../lib/useTranscriptScrollArbiter";
import { useTranscriptLayoutIntegrity } from "../lib/useTranscriptLayoutIntegrity";
import { createTranscriptMeasuredSizes } from "../lib/transcriptMeasuredSizes";
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
import type { TranscriptRow } from "../lib/transcriptRows";
import { buildTranscriptRows, buildTurnModels, EMPTY_FOLDS, transcriptRowMeasurementVersion, type TranscriptRow } from "../lib/transcriptRows";
import type { Item } from "../lib/useController";
import { installTranscriptRaceClock } from "./helpers/transcriptRaceClock";
let passed = 0;
let failed = 0;
@@ -50,42 +52,7 @@ 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;
});
}
const { advanceClock, restore: restoreClock } = installTranscriptRaceClock(dom.window as unknown as Window);
async function flushFrames() {
const pending = [...frames.entries()];
@@ -144,7 +111,7 @@ let integrity: ReturnType<typeof useTranscriptLayoutIntegrity> | undefined;
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 }); },
onItemMeasured: (rowKey, kind, _layoutVariant, height, width) => { rowMeasurements.push({ rowKey, kind, height, width }); },
});
const layout = useTranscriptLayoutIntegrity({
surfaceKey,
@@ -157,7 +124,6 @@ function Probe({ surfaceKey, rows = baseRows, layoutWidth = 800 }: { surfaceKey:
submitRecoveryRequest: scroll.submitRecoveryRequest,
retryRecoveryRequest: scroll.retryRecoveryRequest,
lastGoodAnchorRef: scroll.lastGoodAnchorRef,
captureStateSnapshot: scroll.captureStateSnapshot,
layoutTransientRef: scroll.layoutTransientRef,
layoutWidth,
});
@@ -209,6 +175,7 @@ await act(async () => {
// the estimate Virtuoso started from, so the cache callback must receive the
// returned DOM height instead.
rowElement.dataset.rowKind = "answer";
rowElement.dataset.transcriptLayoutVariant = "text-flow";
rowElement.dataset.knownSize = "291";
rowElement.getBoundingClientRect = () => ({ ...rectAt(200), height: 632, bottom: 832, width: 960, right: 960 });
rowMeasurements.length = 0;
@@ -674,8 +641,8 @@ await act(async () => arbiter?.setMode("manual", "question-navigation"));
await act(async () => arbiter?.scrollToDataIndex(5));
check(scrollToIndexCalls === 1, "question navigation emits one indexed jump after its explicit selection cleanup");
// ── T6: a snapshot captured before the keyed remount restores when the row
// keys still match, and is discarded when they do not.
// ── T6: surface switches reuse safe geometry through the LRU, never an old
// Virtuoso scrollTop. The blank watchdog also discards its broken tree.
stubSnapshot = {
ranges: [{ startIndex: 0, endIndex: 0, size: 100 }, { startIndex: 1, endIndex: Infinity, size: 80 }],
scrollTop: 420,
@@ -687,16 +654,15 @@ check(integrity?.restoreSnapshot === undefined, "watchdog rebuild discards the s
await act(async () => integrity?.handleItemsRendered(1));
await flushFrames();
// Same-tab reveal (new surface, same rows): the snapshot applies and the
// first-mount scrollToBottom is suppressed — it would yank the restored
// view straight back to the tail.
await switchSurface("surface-m");
check(integrity?.restoreSnapshot === stubSnapshot, "same-row surface remount offers the captured snapshot");
readyRef.current = false;
// Same-tab reveal (new surface, same rows) is still a view reset. It opens at
// the product-defined tail and does not restore an old reader position.
const scrollToBottomBeforeSnapshot = scrollToBottomCalls;
await switchSurface("surface-m");
check(integrity?.restoreSnapshot === undefined, "same-row surface remount does not restore an old scrollTop");
check(readyRef.current === false, "surface switch invalidates old readiness before incoming items render");
await act(async () => integrity?.handleItemsRendered(1));
await flushFrames();
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot, "a snapshot-restored mount does not jump to the bottom");
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 1, "same-row reveal follows normal tail positioning");
// ── T9: the incoming surface prepended older history since the capture;
// changed data/totalCount must discard the snapshot per Virtuoso's contract.
@@ -707,22 +673,86 @@ const prependedRows: TranscriptRow[] = [
];
await switchSurface("surface-n", prependedRows);
check(integrity?.restoreSnapshot === undefined, "a prepended key sequence discards the captured snapshot");
readyRef.current = false;
await act(async () => integrity?.handleItemsRendered(1));
await flushFrames();
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 1, "changed data falls back to normal first-mount positioning");
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 2, "changed data falls back to normal first-mount positioning");
// Different session (disjoint keys): the snapshot is discarded and the
// first mount settles at the bottom as before.
const foreignRows: TranscriptRow[] = [{ kind: "answer", key: "row-elsewhere", item: { ...item, id: "elsewhere" } }];
await switchSurface("surface-l", foreignRows);
check(integrity?.restoreSnapshot === undefined, "a disjoint key sequence discards the snapshot");
readyRef.current = false;
await act(async () => integrity?.handleItemsRendered(1));
await flushFrames();
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 2, "a disjoint snapshot-less first mount settles at the bottom");
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 3, "a disjoint snapshot-less first mount settles at the bottom");
stubSnapshot = null;
// A prepended turn may reuse the mounted process id while its content patches.
const duplicateCurrent: Item[] = [
{ kind: "user", id: "u-duplicate-current", text: "current" }, { kind: "phase", id: "duplicate-process-id", text: "working" },
];
const duplicateOptions = { folds: EMPTY_FOLDS, foldPreference: "auto" as const, hasOlderHistory: false, creationMode: false, turnForUser: () => undefined };
const duplicateBeforeRows = buildTranscriptRows(buildTurnModels(duplicateCurrent), duplicateOptions);
const duplicateAfterRows = buildTranscriptRows(buildTurnModels([
{ kind: "user", id: "u-duplicate-older", text: "older" }, { kind: "phase", id: "duplicate-process-id", text: "older work" },
{ kind: "assistant", id: "a-duplicate-older", text: "older answer", reasoning: "", streaming: false }, duplicateCurrent[0],
{ ...duplicateCurrent[1], text: "working with a late patch" } as Item,
{ kind: "assistant", id: "a-duplicate-current", text: "late outside answer", reasoning: "", streaming: false },
]), duplicateOptions);
const duplicateBeforeHeader = duplicateBeforeRows.find((row) => row.kind === "process-header")!;
const duplicateAfterHeader = duplicateAfterRows.find((row) => row.kind === "process-header" && "segment" in row
&& row.segment.processItems.some((item) => item.kind === "phase" && item.text.includes("late patch")))!;
check(duplicateAfterHeader.key === duplicateBeforeHeader.key, "prepend plus outside-content patch preserves the mounted duplicate-process row key");
const duplicateMeasurements = createTranscriptMeasuredSizes();
const duplicateEnvironment = { contentWidth: 800, typographySignature: "race-test" };
duplicateMeasurements.recordGeometry("duplicate-session", { rowKey: String(duplicateBeforeHeader.key), kind: duplicateBeforeHeader.kind,
layoutVariant: duplicateBeforeHeader.layoutVariant, height: 144, environment: duplicateEnvironment,
measurementVersion: transcriptRowMeasurementVersion(duplicateBeforeHeader) });
check(duplicateMeasurements.synthesizeDetailed("duplicate-session", [duplicateBeforeHeader], duplicateEnvironment).estimateSources[0] === "exact", "the mounted duplicate-process row reuses its exact measured height before the patch");
check(duplicateMeasurements.synthesizeDetailed("duplicate-session", [duplicateAfterHeader], duplicateEnvironment).estimateSources[0] !== "exact", "the late process patch invalidates only its stale measurement version");
await switchSurface("surface-duplicate-process", duplicateBeforeRows);
await act(async () => arbiter?.releaseTailFollow());
scrollElement.scrollTop = 160; const duplicateResetKey = integrity?.resetKey;
scrollWrites.length = 0; scrollByCalls = 0; scrollToCalls = 0; scrollToIndexCalls = 0;
await act(async () => root.render(<Probe surfaceKey="surface-duplicate-process" rows={duplicateAfterRows} />));
await flushFrames();
check(integrity?.resetKey === duplicateResetKey, "prepend plus outside-content patch keeps the Virtuoso generation mounted");
check(scrollElement.scrollTop === 160, "prepend plus outside-content patch preserves the reader viewport");
check(scrollByCalls === 0 && scrollToCalls === 0 && scrollToIndexCalls === 0 && scrollWrites.length === 0,
"prepend plus outside-content patch emits no recovery or direct scroll writes");
// Imported pages may also repeat user/assistant ids. The already mounted
// current turn keeps its unsuffixed keys while older duplicates receive stable
// identity hashes, so the prepend does not reset the reader's surface.
const duplicateTurnCurrent: Item[] = [
{ kind: "user", id: "duplicate-turn-user", text: "current", createdAt: 200 },
{ kind: "assistant", id: "duplicate-turn-answer", text: "current answer", reasoning: "", streaming: false },
];
const duplicateTurnBeforeRows = buildTranscriptRows(buildTurnModels(duplicateTurnCurrent), duplicateOptions);
const duplicateTurnAfterRows = buildTranscriptRows(buildTurnModels([
{ kind: "user", id: "duplicate-turn-user", text: "older", createdAt: 100, historyTurn: 1 },
{ kind: "assistant", id: "duplicate-turn-answer", text: "older answer", reasoning: "", streaming: false },
...duplicateTurnCurrent,
]), duplicateOptions);
const duplicateTurnBeforeKeys = duplicateTurnBeforeRows.map((row) => row.key);
const duplicateTurnCurrentKeys = duplicateTurnAfterRows.filter((row) =>
(row.kind === "user" && row.item.text === "current")
|| (row.kind === "answer" && row.item.text === "current answer")
).map((row) => row.key);
check(JSON.stringify(duplicateTurnCurrentKeys) === JSON.stringify(duplicateTurnBeforeKeys),
"prepending duplicate turn ids preserves every mounted current-turn row key");
check(duplicateTurnAfterRows.slice(0, 2).every((row) => String(row.key).includes("@") && !String(row.key).includes("#")),
"older duplicate turn rows use immutable identity hashes instead of occurrence suffixes");
await switchSurface("surface-duplicate-turn", duplicateTurnBeforeRows);
await act(async () => arbiter?.releaseTailFollow());
scrollElement.scrollTop = 180; const duplicateTurnResetKey = integrity?.resetKey;
scrollWrites.length = 0; scrollByCalls = 0; scrollToCalls = 0; scrollToIndexCalls = 0;
await act(async () => root.render(<Probe surfaceKey="surface-duplicate-turn" rows={duplicateTurnAfterRows} />));
await flushFrames();
check(integrity?.resetKey === duplicateTurnResetKey, "duplicate turn prepend keeps the Virtuoso generation mounted");
check(scrollElement.scrollTop === 180, "duplicate turn prepend preserves the reader viewport");
check(scrollByCalls === 0 && scrollToCalls === 0 && scrollToIndexCalls === 0 && scrollWrites.length === 0,
"duplicate turn prepend emits no recovery or direct scroll writes");
// 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}`,
@@ -755,9 +785,7 @@ 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;
dom.window.clearTimeout = originalClearTimeout;
restoreClock();
dom.window.close();
if (failed > 0) {
@@ -0,0 +1,98 @@
// Run: tsx src/__tests__/transcript-row-geometry.test.ts
import assert from "node:assert/strict";
import {
estimateTranscriptRowGeometry,
resolveReasoningLayoutVariant,
resolveToolCardDefaultOpen,
type TranscriptGeometryEnvironment,
} from "../lib/transcriptRowGeometry";
import type { TranscriptRow } from "../lib/transcriptRows";
import type { Item } from "../lib/useController";
const environment: TranscriptGeometryEnvironment = {
contentWidth: 960,
typographySignature: "conversation:14/22|code:12/19|metadata:12/19",
};
function reasoningRow(text: string, layoutVariant: "reasoning-summary" | "reasoning-heading-only" | "reasoning-expanded"): TranscriptRow {
return {
kind: "reasoning",
key: `r:${text.length}`,
item: { kind: "assistant", id: `a:${text.length}`, text: "", reasoning: text, streaming: false },
segmentKey: "segment",
layoutVariant,
};
}
assert.equal(resolveReasoningLayoutVariant("summary", false), "reasoning-summary");
assert.equal(resolveReasoningLayoutVariant("auto", false), "reasoning-summary");
assert.equal(resolveReasoningLayoutVariant("auto", true), "reasoning-expanded");
assert.equal(resolveReasoningLayoutVariant("expanded", false), "reasoning-expanded");
assert.equal(resolveReasoningLayoutVariant("legacy-collapsed", false), "reasoning-heading-only");
assert.equal(resolveReasoningLayoutVariant("hidden", false), null);
assert.equal(resolveReasoningLayoutVariant("pending", false), null);
const shortCollapsed = estimateTranscriptRowGeometry(reasoningRow("十个字的折叠思考内容", "reasoning-summary"), environment);
const hugeCollapsed = estimateTranscriptRowGeometry(reasoningRow("你".repeat(100_000), "reasoning-summary"), environment);
assert.equal(shortCollapsed, hugeCollapsed, "collapsed reasoning geometry is independent of hidden full text");
assert.equal(shortCollapsed, 64, "summary geometry matches the compact browser contract");
assert.equal(
estimateTranscriptRowGeometry(reasoningRow("你".repeat(100_000), "reasoning-heading-only"), environment),
36,
"legacy collapsed reasoning uses the heading-only contract",
);
assert.ok(
estimateTranscriptRowGeometry(reasoningRow("你".repeat(100_000), "reasoning-expanded"), environment) > shortCollapsed,
"expanded reasoning is the only variant that estimates the full body",
);
const markdownAnswer = {
kind: "answer" as const,
key: "answer:markdown",
item: {
kind: "assistant" as const,
id: "answer:markdown",
streaming: false,
reasoning: "",
text: "### Geometry block\n\n折叠布局检查完成,answer 保持内容感知估高。\n\n- 中文换行\n- English wrapping",
},
layoutVariant: "text-flow" as const,
};
const codeAnswer = {
...markdownAnswer,
key: "answer:code",
item: {
...markdownAnswer.item,
id: "answer:code",
text: "### Geometry block\n\n折叠布局检查完成,answer 保持内容感知估高。\n\n```ts\nconst stable = true;\n```",
},
};
assert.equal(estimateTranscriptRowGeometry(markdownAnswer, environment), 157, "structured Markdown follows its final browser geometry");
assert.equal(estimateTranscriptRowGeometry(codeAnswer, environment), 152, "fenced Markdown uses the padded code-block geometry");
const runningTask: Extract<Item, { kind: "tool" }> = {
kind: "tool",
id: "task",
name: "task",
args: "{}",
readOnly: false,
status: "running",
};
assert.equal(resolveToolCardDefaultOpen(runningTask, 2, "summary"), true, "running nested tools default open");
assert.equal(resolveToolCardDefaultOpen({ ...runningTask, status: "done" }, 2, "summary"), false, "settled nested tools default closed");
assert.equal(resolveToolCardDefaultOpen({
...runningTask,
status: "running",
subagentProgress: {
phase: "reasoning",
reasoning: "working",
text: "",
notice: "",
lastActivityAt: 1,
truncated: false,
startedAt: 1,
},
}, 0, "auto"), true, "auto mode follows live subagent reasoning");
console.log("transcript row geometry tests passed");
@@ -59,6 +59,64 @@ const fixture: Item[] = [
{ kind: "assistant", id: "a3", text: "answer two", reasoning: "more", streaming: false },
];
{
const current: Item[] = [
{ kind: "user", id: "u-current", text: "current" },
{ kind: "phase", id: "duplicate-phase", text: "current work" },
];
const before = buildTurnModels(current)[0]?.segments[0]?.key;
const after = buildTurnModels([
{ kind: "user", id: "u-older", text: "older" },
{ kind: "phase", id: "duplicate-phase", text: "older work" },
...current,
])[1]?.segments[0]?.key;
eq(after, before, "prepending history with a duplicate raw id preserves the mounted segment key");
}
{
const reasoningOnly: Item[] = [
{ kind: "user", id: "u-late-answer", text: "question" },
{ kind: "assistant", id: "a-late-answer", text: "", reasoning: "thinking", streaming: false },
];
const before = buildTurnModels(reasoningOnly)[0]?.segments[0]?.key;
const after = buildTurnModels([
reasoningOnly[0],
reasoningOnly[1],
{ kind: "assistant", id: "a-late-answer-visible", text: "resolved answer", reasoning: "", streaming: false },
])[0]?.segments[0]?.key;
eq(after, before, "outside answer content does not remount the existing process segment");
}
{
const currentUser: Item = { kind: "user", id: "duplicate-user", text: "current", createdAt: 200 };
const currentAnswer: Item = { kind: "assistant", id: "duplicate-answer", text: "current answer", reasoning: "", streaming: false };
const options = {
folds: EMPTY_FOLDS,
foldPreference: "auto" as const,
hasOlderHistory: false,
creationMode: false,
turnForUser: () => undefined,
};
const before = buildTranscriptRows(buildTurnModels([currentUser, currentAnswer]), options);
const after = buildTranscriptRows(buildTurnModels([
{ kind: "user", id: "duplicate-user", text: "older", createdAt: 100, historyTurn: 1 },
{ kind: "assistant", id: "duplicate-answer", text: "older answer", reasoning: "", streaming: false },
currentUser,
currentAnswer,
]), options);
const beforeCurrent = before.filter((row) => row.kind === "user" || row.kind === "answer").map((row) => row.key).join(",");
const afterCurrent = after.filter((row) =>
(row.kind === "user" && row.item.text === "current")
|| (row.kind === "answer" && row.item.text === "current answer")
).map((row) => row.key).join(",");
eq(afterCurrent, beforeCurrent, "prepending duplicate user/answer ids preserves the mounted current row keys");
const olderKeys = after.filter((row) =>
(row.kind === "user" && row.item.text === "older")
|| (row.kind === "answer" && row.item.text === "older answer")
).map((row) => row.key);
ok(olderKeys.every((key) => key.includes("@") && !key.includes("#")), "duplicate history rows use immutable identity hashes instead of occurrence suffixes");
}
const turnOf = new Map([
["u1", 0],
["u2", 1],
@@ -84,7 +142,7 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
);
eq(
keys(rows),
"u:u1,ph:a1,n:n1,a:a2,ta:u1,u:u2,ph:a3,a:a3,ta:u2",
`u:u1,ph:${models[0].segments[0].key},n:n1,a:a2,ta:u1,u:u2,ph:${models[1].segments[0].key},a:a3,ta:u2`,
"row keys derive from stable item ids",
);
}
@@ -100,8 +158,9 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
// Expanding a fold inserts its process rows into the model; collapsing
// removes them again. Read-only tools batch; the write tool stays a card.
const models = buildTurnModels(fixture);
const foldKey = models[0].segments[0].key;
let folds = EMPTY_FOLDS;
folds = foldMapWithToggle(folds, "a1", false);
folds = foldMapWithToggle(folds, foldKey, false);
const openRows = buildTranscriptRows(models, rowOptions(folds));
eq(
kinds(openRows),
@@ -110,7 +169,7 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
);
const batch = openRows.find((row) => row.kind === "tool-batch");
eq(batch && "items" in batch ? batch.items.length : 0, 2, "consecutive completed read-only tools batch into one row");
const collapsed = buildTranscriptRows(models, rowOptions(foldMapWithToggle(folds, "a1", true)));
const collapsed = buildTranscriptRows(models, rowOptions(foldMapWithToggle(folds, foldKey, true)));
eq(kinds(collapsed), "user,process-header,notice,answer,turn-actions,user,process-header,answer,turn-actions", "collapsing removes the process rows again");
}
@@ -234,13 +293,14 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
eq(defaultFoldOpen(states[0], "auto"), true, "running folds default open");
const seeded = reconcileFoldEntries(EMPTY_FOLDS, states, "auto", false);
ok(seeded?.get("a1")?.open === true, "reconcile seeds running folds open");
const foldKey = states[0].key;
ok(seeded?.get(foldKey)?.open === true, "reconcile seeds running folds open");
const settledModels = buildTurnModels(fixture.slice(0, 7), undefined, false);
const settledStates = foldSegmentStates(settledModels);
eq(settledStates[0].hasRunningWork, false, "settled turn clears the running flag");
const closed = reconcileFoldEntries(seeded ?? EMPTY_FOLDS, settledStates, "auto", false);
ok(closed?.get("a1")?.open === false, "completion auto-closes an untouched fold");
ok(closed?.get(foldKey)?.open === false, "completion auto-closes an untouched fold");
eq(reconcileFoldEntries(closed ?? EMPTY_FOLDS, settledStates, "auto", false), null, "steady state reconciles to no change");
}
@@ -248,31 +308,33 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
// Expanded reasoning pins only reasoning-bearing folds across completion.
const running = buildTurnModels(fixture.slice(0, 7), { id: "a2", hasAnswerText: true, hasReasoning: false, reasoningComplete: true }, true);
const runningStates = foldSegmentStates(running, true);
const foldKey = runningStates[0].key;
eq(runningStates[0]?.keepReasoningExpanded, true, "expanded mode marks a reasoning-bearing fold as pinned");
const seeded = reconcileFoldEntries(EMPTY_FOLDS, runningStates, "auto", false) ?? EMPTY_FOLDS;
const settledModels = buildTurnModels(fixture.slice(0, 7), undefined, false);
const settledPinnedStates = foldSegmentStates(settledModels, true);
const completed = reconcileFoldEntries(seeded, settledPinnedStates, "auto", false);
ok(completed?.get("a1")?.open === true, "expanded reasoning keeps its parent fold open after completion");
ok(completed?.get(foldKey)?.open === true, "expanded reasoning keeps its parent fold open after completion");
const manuallyCollapsed = foldMapWithToggle(seeded, "a1", true);
const manuallyCollapsed = foldMapWithToggle(seeded, foldKey, true);
const completedCollapsed = reconcileFoldEntries(manuallyCollapsed, settledPinnedStates, "auto", false);
ok(completedCollapsed?.get("a1")?.open === false, "manual parent collapse still wins when expanded reasoning completes");
ok(completedCollapsed?.get(foldKey)?.open === false, "manual parent collapse still wins when expanded reasoning completes");
const settledAutoStates = foldSegmentStates(settledModels, false);
const backToAuto = reconcileFoldEntries(completed ?? EMPTY_FOLDS, settledAutoStates, "auto", false);
ok(backToAuto?.get("a1")?.open === false, "leaving expanded reasoning re-applies the automatic parent fold policy");
ok(backToAuto?.get(foldKey)?.open === false, "leaving expanded reasoning re-applies the automatic parent fold policy");
}
{
// User override survives completion; a fold with nothing outside never
// auto-closes.
const overridden: FoldMap = new Map([["a1", { open: true, userOverridden: true, running: true }]]);
const settledModels = buildTurnModels(fixture.slice(0, 7), undefined, false);
const states = foldSegmentStates(settledModels);
const foldKey = states[0].key;
const overridden: FoldMap = new Map([[foldKey, { open: true, userOverridden: true, running: true }]]);
const next = reconcileFoldEntries(overridden, states, "auto", false);
ok(next?.get("a1")?.open === true, "user-opened fold survives completion");
ok(next?.get(foldKey)?.open === true, "user-opened fold survives completion");
const soloModels = buildTurnModels([
{ kind: "user", id: "u5", text: "cancelled" },
@@ -285,13 +347,14 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
{
// Preference switches apply to existing folds and clear manual overrides.
const seeded: FoldMap = new Map([["a1", { open: true, userOverridden: true, running: false }]]);
const models = buildTurnModels(fixture.slice(0, 7), undefined, false);
const states = foldSegmentStates(models);
const foldKey = states[0].key;
const seeded: FoldMap = new Map([[foldKey, { open: true, userOverridden: true, running: false }]]);
const expanded = reconcileFoldEntries(seeded, states, "expanded", true);
ok(expanded?.get("a1")?.open === true && expanded.get("a1")?.userOverridden === false, "switching to expanded opens folds and clears overrides");
ok(expanded?.get(foldKey)?.open === true && expanded.get(foldKey)?.userOverridden === false, "switching to expanded opens folds and clears overrides");
const backToAuto = reconcileFoldEntries(expanded ?? EMPTY_FOLDS, states, "auto", true);
ok(backToAuto?.get("a1")?.open === false, "switching back to auto re-closes completed folds");
ok(backToAuto?.get(foldKey)?.open === false, "switching back to auto re-closes completed folds");
const pruned = reconcileFoldEntries(backToAuto ?? EMPTY_FOLDS, [], "auto", false);
ok(pruned?.size === 0, "vanished segments are pruned from the fold map");
@@ -309,6 +372,18 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
eq(historyEntryIdForRow({ kind: "older-history", key: "older-history" }), undefined, "the paging row has no entry");
}
{
const localOnly: Item[] = [
{ kind: "user", id: "u1", text: "first" },
{ kind: "tool", id: "__reasonix_local_only__", name: "__reasonix_local_only__", args: "", readOnly: false, status: "done", output: "partial one" },
{ kind: "user", id: "u2", text: "second" },
{ kind: "tool", id: "__reasonix_local_only__", name: "__reasonix_local_only__", args: "", readOnly: false, status: "done", output: "partial two" },
];
const rows = buildTranscriptRows(buildTurnModels(localOnly), rowOptions(EMPTY_FOLDS, "expanded"));
const rowKeys = rows.map((row) => row.key);
eq(new Set(rowKeys).size, rowKeys.length, "two local-only recoveries cannot share ph:/t: row keys");
}
// ── Size estimates ────────────────────────────────────────────────────────────
{
@@ -143,17 +143,30 @@ const detailDiagnostics = createTranscriptScrollDiagnostics({
detailDiagnostics.start();
detailDiagnostics.record("row-measure", {
rowIndex: 44,
rowKind: "answer",
estimatedSize: 1_800,
previousSize: 1_800,
measuredSize: 420,
sizeDelta: -1_380,
rowKind: "reasoning",
layoutVariant: "reasoning-summary",
estimateSource: "static",
estimatedSize: 64,
previousSize: 64,
measuredSize: 64,
sizeDelta: 0,
contentRevision: 3,
foldState: "closed",
disclosureCount: 1,
rowKey: "PRIVATE_ROW_KEY",
transcriptText: "PRIVATE_TRANSCRIPT_CANARY",
});
detailDiagnostics.record("geometry-contract-violation", {
rowIndex: 45,
rowKind: "tool",
layoutVariant: "tool-collapsed",
estimateSource: "static",
estimatedSize: 37,
measuredSize: 55,
sizeDelta: 18,
relativeError: 18 / 37,
rowKey: "PRIVATE_ROW_KEY_2",
});
detailDiagnostics.record("scroll-state", {
source: "jump-bottom",
previousMode: "manual",
@@ -184,20 +197,38 @@ assert.deepEqual(rowMeasurement, {
t: 0,
type: "row-measure",
rowIndex: 44,
estimatedSize: 1_800,
previousSize: 1_800,
measuredSize: 420,
sizeDelta: -1_380,
estimatedSize: 64,
previousSize: 64,
measuredSize: 64,
sizeDelta: 0,
contentRevision: 3,
disclosureCount: 1,
rowKind: "answer",
rowKind: "reasoning",
layoutVariant: "reasoning-summary",
estimateSource: "static",
foldState: "closed",
});
const geometryViolation = detailPayload.events.find((event) => event.type === "geometry-contract-violation");
assert.deepEqual(geometryViolation, {
t: 0,
type: "geometry-contract-violation",
rowIndex: 45,
estimatedSize: 37,
measuredSize: 55,
sizeDelta: 18,
relativeError: 0.49,
rowKind: "tool",
layoutVariant: "tool-collapsed",
estimateSource: "static",
});
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(detailSerialized.includes("PRIVATE_ROW_KEY_2"), false, "geometry violations exclude stable row keys");
assert.equal(isTranscriptScrollDiagnosticsBuild("test", false), true, "test builds expose diagnostics");
assert.equal(isTranscriptScrollDiagnosticsBuild("preview", false), true, "preview builds expose diagnostics");
assert.equal(isTranscriptScrollDiagnosticsBuild("canary", false), true, "canary builds expose diagnostics");
assert.equal(isTranscriptScrollDiagnosticsBuild("stable", true), true, "development server exposes diagnostics");
assert.equal(isTranscriptScrollDiagnosticsBuild("stable", false), false, "stable production hides diagnostics");
@@ -8,7 +8,12 @@ import {
type TranscriptScrollEvent,
type TranscriptScrollState,
} from "../lib/transcriptScrollArbiter";
import { pinTranscriptScrollerToNativeTail, pinTranscriptTailAfterViewportShrink } from "../lib/transcriptScrollGeometry";
import { pinTranscriptTailAfterViewportShrink } from "../lib/transcriptScrollGeometry";
import {
TRANSCRIPT_TAIL_REARM_MIN_HEIGHT_PX,
transcriptTailSettleBudgetExhausted,
transcriptTailShouldReaim,
} from "../lib/transcriptTailSettle";
let passed = 0;
let failed = 0;
@@ -221,6 +226,12 @@ check(
"delivered displacement and later growth both reconverge while tail-follow owns the viewport",
);
check(transcriptTailSettleBudgetExhausted(0) === false, "tail settle may re-aim before its bounded budget is spent");
check(transcriptTailSettleBudgetExhausted(8) === true, "tail settle stops at its bounded re-aim budget");
check(transcriptTailShouldReaim(null, 1_000) === true, "a fresh tail settle always re-aims");
check(transcriptTailShouldReaim(1_000, 1_000 + TRANSCRIPT_TAIL_REARM_MIN_HEIGHT_PX - 1) === false, "sub-threshold tail measurement jitter does not re-aim");
check(transcriptTailShouldReaim(1_000, 1_000 + TRANSCRIPT_TAIL_REARM_MIN_HEIGHT_PX) === true, "real tail growth re-arms the settle writer");
const repeatedDisplacement = run([
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
{ type: "SCROLL_DELIVERED", atBottom: false, scrollable: true },
@@ -256,9 +267,9 @@ check(
);
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");
check(pinTranscriptTailAfterViewportShrink(wrapScroller, { contentExtent: 500, viewportExtent: 100 }, true) === 420, "a composer-wrap shrink returns the native tail target");
check(wrapScroller.scrollTop === 400, "geometry helper does not write the native scroll position");
check(pinTranscriptTailAfterViewportShrink(wrapScroller, { contentExtent: 500, viewportExtent: 80 }, true) === null, "the same shrink revision does not schedule a second tail write");
const foldScroller = { scrollHeight: 500, scrollTop: 400, clientHeight: 80 };
check(
@@ -0,0 +1,71 @@
// Run: tsx src/__tests__/transcript-scroll-writer.test.ts
import { equal } from "node:assert/strict";
import { JSDOM } from "jsdom";
import type { VirtuosoHandle } from "react-virtuoso";
import { createTranscriptScrollWriter } from "../lib/transcriptScrollWriter";
import type { TranscriptScrollMode } from "../lib/transcriptScrollArbiter";
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
const dom = new JSDOM("<div id='scroll'></div>", { pretendToBeVisual: true });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
const element = dom.window.document.getElementById("scroll") as HTMLDivElement;
Object.defineProperties(element, {
scrollTop: { configurable: true, writable: true, value: 200 },
scrollHeight: { configurable: true, value: 2_000 },
clientHeight: { configurable: true, value: 500 },
});
const calls: string[] = [];
const handle = {
scrollTo: () => calls.push("scrollTo"),
scrollBy: () => calls.push("scrollBy"),
scrollToIndex: () => calls.push("scrollToIndex"),
} as unknown as VirtuosoHandle;
const generationRef = { current: 4 };
const modeRef = { current: "manual" as TranscriptScrollMode };
const records: TranscriptScrollWriteRecord[] = [];
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (record) => records.push(record);
const writer = createTranscriptScrollWriter({
virtuosoRef: { current: handle },
scrollRef: { current: element },
modeRef,
generationRef,
});
equal(writer.write({
owner: "reader-stability",
operation: "scrollBy",
top: 120,
source: "reader-rebound",
expectedGeneration: 4,
geometryRevision: 9,
}), true, "the current generation may write");
equal(calls.join(","), "scrollBy", "the gateway emits the requested operation once");
equal(records[0]?.generation, 4, "the diagnostic binds the write to its generation");
equal(records[0]?.geometryRevision, 9, "the diagnostic binds the write to its geometry revision");
equal(records[0]?.sequence, 1, "the gateway assigns a monotonic sequence");
equal(writer.write({
owner: "recovery",
operation: "scrollTo",
top: 600,
source: "stale-recovery",
expectedGeneration: 3,
geometryRevision: 9,
}), false, "a stale generation cannot write to the replacement surface");
equal(calls.length, 1, "a rejected stale write never reaches Virtuoso");
modeRef.current = "native-thumb";
equal(writer.write({
owner: "tail-follow",
operation: "scrollTo",
top: 1_500,
source: "tail-settle",
expectedGeneration: 4,
geometryRevision: 10,
}), false, "native-thumb ownership suppresses every imperative writer");
equal(calls.length, 1, "the native thumb remains browser-owned");
dom.window.close();
console.log("\ntranscript scroll writer tests passed");
@@ -98,6 +98,14 @@ async function dispatchContextMenu(target: Element, clientX = 120, clientY = 80)
return event;
}
function transcriptActionHost(): HTMLElement | null {
return document.querySelector<HTMLElement>('.transcript-selection-action[data-surface="transcript"]');
}
function transcriptActionState(): string | undefined {
return transcriptActionHost()?.dataset.state;
}
console.log("\ntranscript selection menu");
{
@@ -135,6 +143,28 @@ console.log("\ntranscript selection menu");
await flushTimers();
});
const stableActionHost = transcriptActionHost();
eq(document.querySelectorAll('.transcript-selection-action[data-surface="transcript"]').length, 1, "transcript owns exactly one stable action host");
eq(transcriptActionState(), "closed", "the stable action host starts closed");
eq(stableActionHost?.getAttribute("aria-hidden"), "true", "the closed action host is hidden from assistive technology");
eq(stableActionHost?.querySelector("button")?.getAttribute("tabindex"), "-1", "the closed action button leaves the tab order");
eq((stableActionHost?.querySelector("button") as HTMLButtonElement | null)?.disabled, true, "the closed action button cannot be activated");
for (let cycle = 1; cycle <= 4; cycle += 1) {
selectNodeText(msgBody.firstChild as Node);
await act(async () => {
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
await drainFrame();
});
eq(transcriptActionHost(), stableActionHost, `selection cycle ${cycle} reuses the stable action host`);
eq(transcriptActionState(), "open", `selection cycle ${cycle} opens the stable action host`);
await act(async () => {
document.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, button: 0 }));
await flushTimers();
});
eq(transcriptActionState(), "closed", `selection cycle ${cycle} closes without unmounting the host`);
}
// Message selection opens the menu and suppresses the (already dead) default.
selectNodeText(msgBody.firstChild as Node);
const openEvent = await dispatchContextMenu(msgBody);
@@ -173,7 +203,7 @@ console.log("\ntranscript selection menu");
});
eq(additions[0], "assistant reply text", "Add to Chat forwards the exact selected text");
eq(document.getSelection()?.isCollapsed, true, "Add to Chat clears the browser selection");
eq(document.querySelector(".transcript-selection-action"), null, "Add to Chat closes the floating action");
eq(transcriptActionState(), "closed", "Add to Chat closes the floating action without unmounting it");
// The shortcut is scoped to a live transcript selection, so it cannot steal
// Cmd/Ctrl+L during normal app navigation.
@@ -195,23 +225,23 @@ console.log("\ntranscript selection menu");
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
await drainFrame();
});
ok(document.querySelector(".transcript-selection-action") != null, "pointer selection re-exposes the floating action");
eq(transcriptActionState(), "open", "pointer selection re-exposes the floating action");
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushTimers();
});
eq(document.querySelector(".transcript-selection-action"), null, "Escape dismisses the floating action");
eq(transcriptActionState(), "closed", "Escape dismisses the floating action without unmounting it");
eq(document.getSelection()?.isCollapsed, false, "Escape keeps the browser selection");
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Escape", bubbles: true }));
await drainFrame();
});
eq(document.querySelector(".transcript-selection-action"), null, "the Escape keyup does not re-open the dismissed action");
eq(transcriptActionState(), "closed", "the Escape keyup does not re-open the dismissed action");
await act(async () => {
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
await drainFrame();
});
ok(document.querySelector(".transcript-selection-action") != null, "a fresh pointer gesture re-opens the dismissed action");
eq(transcriptActionState(), "open", "a fresh pointer gesture re-opens the dismissed action");
// A new left-click must release the previous browser range before the
// WebView's selectionchange event arrives. This keeps a stale selection from
@@ -222,7 +252,7 @@ console.log("\ntranscript selection menu");
await flushTimers();
});
eq(document.getSelection()?.isCollapsed, true, "a new left-click clears the previous transcript selection");
eq(document.querySelector(".transcript-selection-action"), null, "clearing the selection closes the floating action");
eq(transcriptActionState(), "closed", "clearing the selection closes the floating action");
selectNodeText(msgBody.firstChild as Node);
await act(async () => {
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
@@ -265,7 +295,7 @@ console.log("\ntranscript selection menu");
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
await drainFrame();
});
ok(document.querySelector(".transcript-selection-action") != null, "selection opens the floating action before a tab switch");
eq(transcriptActionState(), "open", "selection opens the floating action before a tab switch");
await act(async () => {
root.render(
<LocaleProvider>
@@ -274,7 +304,7 @@ console.log("\ntranscript selection menu");
);
await flushTimers();
});
eq(document.querySelector(".transcript-selection-action"), null, "a tab switch discards the captured selection action");
eq(transcriptActionState(), "closed", "a tab switch discards the captured selection action without unmounting the host");
eq(document.getSelection()?.isCollapsed, true, "a tab switch clears the native transcript selection");
await act(async () => {
root.render(
@@ -288,7 +318,7 @@ console.log("\ntranscript selection menu");
document.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Meta", bubbles: true }));
await drainFrame();
});
eq(document.querySelector(".transcript-selection-action"), null, "keyup over a hydration placeholder cannot re-summon the action");
eq(transcriptActionState(), "closed", "keyup over a hydration placeholder cannot re-summon the action");
selectNodeText(msgBody.firstChild as Node);
const disabledContextEvent = await dispatchContextMenu(msgBody);
eq(disabledContextEvent.defaultPrevented, false, "disabled selection handling leaves the native context menu alone");
@@ -0,0 +1,96 @@
// Run: tsx src/__tests__/transcript-selection-rendering.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(resolve(testDir, "../components/TranscriptSelectionMenu.css"), "utf8");
const transcriptSelectionSource = readFileSync(
resolve(testDir, "../components/TranscriptSelectionMenu.tsx"),
"utf8",
);
const terminalSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
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 ruleBody(selector: string): string {
const start = styles.indexOf(`${selector} {`);
if (start < 0) return "";
const bodyStart = styles.indexOf("{", start) + 1;
const bodyEnd = styles.indexOf("\n}", bodyStart);
return bodyEnd < 0 ? "" : styles.slice(bodyStart, bodyEnd);
}
console.log("\ntranscript selection rendering");
ok(
transcriptSelectionSource.includes('data-surface="transcript"')
&& transcriptSelectionSource.includes("data-state={actionOverlay.phase}")
&& transcriptSelectionSource.includes("aria-hidden={actionOverlay.phase !== \"open\"}")
&& transcriptSelectionSource.includes("disabled={actionOverlay.phase !== \"open\"}")
&& transcriptSelectionSource.includes('tabIndex={actionOverlay.phase === "open" ? 0 : -1}'),
"transcript selection action keeps one state-driven, accessibility-safe portal host",
);
ok(
transcriptSelectionSource.includes("actionOverlayStateRef.current.action")
&& !transcriptSelectionSource.includes("{action && typeof document !== \"undefined\" && createPortal("),
"selection state changes neither remount the portal nor reinstall listeners through an action dependency",
);
const closedRule = ruleBody('.transcript-selection-action[data-surface="transcript"]');
const openRule = ruleBody(
'.transcript-selection-action[data-surface="transcript"][data-state="open"]',
);
const windowsRule = ruleBody(
':root[data-platform="windows"] .transcript-selection-action[data-surface="transcript"]',
);
ok(
/visibility:\s*hidden;/.test(closedRule)
&& /opacity:\s*0;/.test(closedRule)
&& /pointer-events:\s*none;/.test(closedRule)
&& /animation:\s*none;/.test(closedRule),
"closed and positioning transcript actions stay mounted without painting or hit testing",
);
ok(
/visibility:\s*visible;/.test(openRule)
&& /opacity:\s*1;/.test(openRule)
&& /pointer-events:\s*auto;/.test(openRule)
&& /animation:\s*menu-pop-in/.test(openRule),
"non-Windows transcript actions retain the existing open animation",
);
ok(
/background-color:\s*Canvas;/.test(windowsRule)
&& /background-image:\s*linear-gradient\(var\(--bg-elev\),\s*var\(--bg-elev\)\);/.test(windowsRule)
&& /-webkit-backdrop-filter:\s*none;/.test(windowsRule)
&& /backdrop-filter:\s*none;/.test(windowsRule)
&& /animation:\s*none;/.test(windowsRule)
&& /transition:\s*none;/.test(windowsRule)
&& /transform:\s*none;/.test(windowsRule)
&& /isolation:\s*isolate;/.test(windowsRule),
"Windows transcript actions use an opaque non-transforming WebView2 paint path",
);
ok(
terminalSource.includes('className="transcript-selection-action"')
&& !terminalSource.includes('data-surface="transcript"'),
"the Windows transcript paint override does not affect terminal selection actions",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
Loaded 100 of 697 files, more files were not shown because too many files have changed in this diff. Show more