diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 731521ea8d6..f6f2c0bd13c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -20,6 +20,7 @@ on: - 'config/scripts/computer-use-smoke.mjs' - 'config/scripts/computer-use-smoke.test.mjs' - 'config/scripts/daemon-boot-smoke.mjs' + - 'config/scripts/daemon-endpoint-handover-smoke.mjs' - 'config/scripts/windows-daemon-workspace-close-repro.mjs' - 'config/scripts/verify-computer-native.mjs' # Why: the native-smoke job boots the built terminal daemon under plain @@ -135,6 +136,11 @@ jobs: # the PR when the built daemon cannot start on ubuntu-22.04 / windows. - name: Daemon boot smoke run: node config/scripts/daemon-boot-smoke.mjs + # Why: a daemon that lost its endpoint name used to delete the replacement's + # socket on exit, stranding a live daemon that hosts PTYs nothing can reach. + # Only two real processes racing the same endpoint reproduce it. + - name: Daemon endpoint handover smoke + run: node config/scripts/daemon-endpoint-handover-smoke.mjs # Why: workspace removal overlaps graceful renderer teardown with the # forced main-process sweep; exercise that exact pair against real ConPTY. - name: Windows daemon workspace-close repro diff --git a/.oxlintrc.json b/.oxlintrc.json index 4cdb4f2c0e2..0f679f17d52 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,10 +150,5 @@ } } ], - "ignorePatterns": [ - "**/node_modules", - "**/dist", - "**/out", - "tests/e2e/.cross-version-checkouts" - ] + "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] } diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 2a3b4603f36..b37d3374bf5 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -6,6 +6,13 @@ "dynamic": false, "count": 1 }, + { + "filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx", + "kind": "jsx-attribute:label", + "text": "sidebar", + "dynamic": false, + "count": 1 + }, { "filePath": "src/renderer/src/components/settings/appearance-search.ts", "kind": "object-property:keywords", diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 17cfd44cc18..f7935dbfc1f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4196,23 +4196,28 @@ "snapshot-probe sequence gap recovery", "hidden remote worktree retention", "remote terminal reveal and input", + "paired host relaunch with preserved daemon PTYs", "manual server disconnect" ], "platforms": ["macos", "linux", "windows"], "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=10 --max-failures=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", @@ -4228,6 +4233,8 @@ "src/main/ipc/runtime-environments.test.ts", "src/main/runtime/rpc/terminal-multiplex.test.ts", "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "src/main/ipc/pty.test.ts", "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts", "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", @@ -4248,9 +4255,38 @@ "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts", "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", - "tests/e2e/terminal-parked-memory.spec.ts" + "tests/e2e/terminal-parked-memory.spec.ts", + "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "assertions": ["retries an existing subscriber when provider inventory becomes ready"] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "foregrounds a preserved daemon PTY after handler recreation loses sync memory exactly once" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-multiplex.test.ts", + "assertions": ["keeps layout versions out of the output sequence domain"] + }, + { + "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "assertions": [ + "omits an unknown snapshot output sequence instead of publishing the layout version" + ] + }, + { + "file": "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts", + "assertions": [ + "preserves daemon PID and target PTY identity across the paired host relaunch", + "foregrounds the daemon-backgrounded target on its first post-relaunch remote observation", + "delivers target and post-relaunch control terminal input and live output" + ] + }, { "file": "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", "assertions": ["replaces a stale paired stream when the PTY snapshot advanced"] @@ -4379,6 +4415,42 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "result": "passed", + "durationSeconds": 4.29, + "summary": "All 10 subscriber-driven attach contracts passed. With two authoritative inventories overlapping one refused in-flight attach, removing only the post-settlement retry made the byte-identical focused assertion fail with one attach instead of two; restoring it retained one bounded waiter, retried once, and resumed live output on the existing stream." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 5.84, + "summary": "The full PTY IPC file passed 449 tests, including one first-observation foreground sync after handler recreation and deduplication of the repeated presence signal." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=10 --max-failures=1", + "result": "passed", + "durationSeconds": 126, + "summary": "Ten consecutive isolated host-relaunch journeys preserved the daemon and PTY identities and resumed terminal input plus live output after the paired transport recovered." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 12.5, + "summary": "With the parking override captured by both host launches, the isolated headed host and separate paired desktop client retained the daemon PID and PTY ID across host relaunch, emitted one target foreground transition, and delivered target plus post-relaunch control input/output. The byte-identical run with only the unknown-state fix reverted failed at the missing target foreground transition." + }, { "date": "2026-08-02", "runner": "local", @@ -4503,15 +4575,15 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "The paired headed oracle is deterministic locally but has no CI or soak history yet." + "evidence": "The host-relaunch oracle passed a fresh 10-run local series. One earlier exploratory repeat timed out in transport recovery before terminal input/output assertions; it did not recur with forwarded app logs. A later Linux CI run reproduced missing live output when inventory overlapped the refused in-flight attach, which now has a deterministic unit oracle and is pending cross-platform rerun. Broader headed/headless parking coverage has no aggregated CI or soak history yet." }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." }, "performanceBudget": { "required": true, - "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream." + "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", @@ -5319,7 +5391,7 @@ "id": "terminal-provider.daemon-startup-degraded-contract", "title": "Daemon startup reconcile and degraded fallback preserve provider identity", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "terminal-provider", "layer": "provider-contract", "surfaces": [ @@ -5327,26 +5399,75 @@ "degraded daemon", "fallback PTY", "provider ownership", - "startup restore" + "startup restore", + "stable-pane reopen" ], "platforms": ["macos", "linux", "windows"], "providers": ["daemon", "local"], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap on main. The fail-closed degraded-daemon hardening and its contracts exist only on the pending reliability stack. It registers here with its owning split PR.", + "coveredPlatforms": ["macos"], + "coveredProviders": ["daemon", "local"], + "coverageNotes": "Deterministic provider contracts cover complete, incomplete, conflicting, refused, and identity-invalidated owner inventories; degraded fallback/current/legacy routing; repeated stale-binding classification; and fresh-session reattach without local fallback during unresolved ownership. Main IPC contracts cover exact persisted-binding retirement and one fresh replacement after confirmed absence. Live Linux, Windows, WSL, SSH, paired-runtime, and packaged-upgrade journeys remain uncollected.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6830", "https://github.com/stablyai/orca/pull/6866", - "https://github.com/stablyai/orca/pull/7002" + "https://github.com/stablyai/orca/pull/7002", + "https://github.com/stablyai/orca/pull/12776" + ], + "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions and reap only true orphans. A persisted binding stays fail-closed while any possible owner is incomplete, conflicting, refusing attach, or identity-invalidated, but complete authoritative absence from every configured provider must retire that exact stale binding and let retry or reopen converge without attaching it through local fallback or duplicating a PTY.", + "oracle": "Hold exact fallback, current-daemon, and legacy-daemon inventories behind controlled promises. No attach may settle or dispatch until every provider answers. Complete zero-candidate inventories must report SessionNotFound; incomplete, duplicate, refused, and identity-invalidated attempts must remain owner-unverified. In degraded mode, repeated stale attempts scan each eligible provider once per attempt and dispatch no attach, then one explicit fresh spawn and exact reattach use only the recorded fresh route. Main IPC must compare-and-swap retire only the matching persisted PTY/incarnation, emit one synthetic exit, and create one different fresh PTY.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/daemon/daemon-session-owner-resolution.test.ts", + "src/main/daemon/daemon-pty-router.test.ts", + "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/ipc/pty.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/daemon-session-owner-resolution.test.ts", + "assertions": [ + "controlled concurrent inventories wait for fallback, current, and legacy authority before classifying exact persisted PTYs absent", + "incomplete inventory, duplicate claims, owner refusal, and identity replacement stay fail-closed with exact list and spawn counts" + ] + }, + { + "file": "src/main/daemon/daemon-pty-router.test.ts", + "assertions": [ + "complete current and legacy daemon inventories prove liveness absence with one list call per adapter", + "one unavailable daemon inventory keeps the same missing PTY liveness unknown" + ] + }, + { + "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "assertions": [ + "repeated complete absence never dispatches the stale id to fallback or either daemon", + "one explicit fresh fallback PTY reattaches by its exact PTY and incarnation without another inventory", + "a mapped fallback-owned PTY reports live without borrowing fallback authority for unknown ids or scanning daemon inventories" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "confirmed absence retires the exact persisted binding once and creates one differently identified fresh PTY", + "unverified ownership retains the binding and creates no fresh PTY" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-06", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 5.44, + "summary": "Four files passed 535 owner-resolution, router, degraded-provider, and IPC lifecycle tests. With only the resolver fix disabled, the unchanged four-file gate failed four stale-absence, identity-retry, and router-liveness assertions; restoring it passed 535 of 535." + } ], - "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions, reap only true orphans, and degraded mode must not route an existing-looking daemon session through local fallback unless the caller explicitly marks a fresh degraded-mode spawn.", - "oracle": "The current provider-contract corpus covers valid current-worktree ids, folder/floating workspace ids, invalid removed-worktree ids, mixed live/orphan dry-run reconciliation, hyphenated worktree ids, malformed ids, daemon sessions discovered after restart, router discovery before existing-session spawn, router fail-closed behavior when legacy ownership cannot be listed or a known session has exited, degraded daemon fallback for fresh sessions, fail-closed behavior for unknown restored ids, benign inspection defaults for unknown ownership, and synthetic exits on daemon restart. Prior-worktree aliases and renamed-worktree startup wiring are promotion gaps, not current proof.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], "runtimeBudget": { - "p95Seconds": 20, + "p95Seconds": 45, "scope": "provider contract unit/integration test" }, "flakeHistory": { @@ -5354,22 +5475,22 @@ "evidence": "Focused provider contract tests now run locally; needs soak history before promotion." }, "redGreenEvidence": { - "status": "partial", - "evidence": "Tests assert discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy listing fails or a known session has exited, targeted hasPty discovery caches legacy ownership before later write/resize-style operations, real daemon adapter listProcesses discovery seeds targeted hasPty liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, operations on unknown or exited existing ids fail closed, startup reconcile can dry-run orphan detection without killing live sessions, and process inspection returns benign defaults for unknown ownership. Production startup reconcile wiring remains unproven." + "status": "complete", + "evidence": "The byte-identical owner-resolver blob d35d2795d56e7e32c1ca99af6726fe6b92181881 is present in v1.4.176-rc.0@ddf64199fa, prod-release-1.4.176@8ddf575fe6, and origin/main@cb960408f2. It returns TerminalSessionOwnerUnverifiedError for both controlled complete-empty persisted PTYs and leaves degraded stale bindings unrecoverable. The candidate passes 535 of 535 registered tests. Restoring only the old predicate makes the unchanged four-file gate fail four of 535 tests; restoring the fix passes 535 of 535." }, "performanceBudget": { "required": true, - "evidence": "Reconcile must not add startup-blocking scans beyond the explicit daemon session inventory and must not leak global listing into hot paths." + "evidence": "One unresolved attempt performs one concurrent listProcesses call per eligible provider, concurrent pane attempts coalesce onto that inventory, and no polling, sleep, timer, subprocess, listener, or retry loop is added. Confirmed absence is not cached; repeated explicit attempts remain bounded to one fanout each. Reattaching the newly recorded fresh route adds no inventory or daemon spawn." }, "promotionCriteria": [ "Wire reconcileOnStartup or mark the production wiring gap explicitly.", "Cover priorWorktreeIds so renamed worktrees are not falsely reaped." ], "knownGaps": [ - "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", "Production startup reconcile wiring remains unproven.", "Prior-worktree aliases and renamed-worktree startup reconcile are not covered by the current executable corpus.", - "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests." + "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests.", + "No live Linux, Windows, WSL, SSH, headed/headless paired-runtime, folder-workspace, or mixed-version packaged-upgrade journey was run for stale-owner recovery." ], "demotionRule": "Cannot promote while restored daemon ids or routing operations can silently route to local fallback." }, diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index 8b978e38ed4..fd750a9150d 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -60,6 +60,14 @@ const USER_VISIBLE_OBJECT_METHODS = new Set([ 'warning' ]) const USER_VISIBLE_OBJECT_NAMES = new Set(['toast']) +// Why: only comparison operands are code, not copy. Bailing on every non-`+` +// operator hid whole subtrees behind `cond && ` guards and `?? 'fallback'`. +const COPY_PRESERVING_BINARY_OPERATORS = new Set([ + ts.SyntaxKind.PlusToken, + ts.SyntaxKind.QuestionQuestionToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.AmpersandAmpersandToken +]) function normalizePath(root, filePath) { return path.relative(root, filePath).split(path.sep).join('/') @@ -226,7 +234,7 @@ function isRenderedJsxExpression(node) { continue } if (ts.isBinaryExpression(current)) { - if (current.operatorToken.kind !== ts.SyntaxKind.PlusToken) { + if (!COPY_PRESERVING_BINARY_OPERATORS.has(current.operatorToken.kind)) { return false } current = current.parent @@ -318,7 +326,8 @@ function classifyStringNode(node) { findAncestor( node, (ancestor) => - ts.isBinaryExpression(ancestor) && ancestor.operatorToken.kind !== ts.SyntaxKind.PlusToken + ts.isBinaryExpression(ancestor) && + !COPY_PRESERVING_BINARY_OPERATORS.has(ancestor.operatorToken.kind) ) ) { return undefined diff --git a/config/scripts/audit-localization-coverage.test.mjs b/config/scripts/audit-localization-coverage.test.mjs new file mode 100644 index 00000000000..291e6e29595 --- /dev/null +++ b/config/scripts/audit-localization-coverage.test.mjs @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { collectLocalizationCandidates } from './audit-localization-coverage.mjs' + +const ROOT = process.cwd() + +function candidates(fileName, source) { + return collectLocalizationCandidates(`${ROOT}/src/renderer/src/${fileName}`, source, ROOT) +} + +describe('localization coverage candidates', () => { + it('sees copy guarded by a nullish or logical fallback', () => { + const reports = candidates( + 'Sample.tsx', + `export function Sample({ label, connecting }) { + return {label ?? (connecting ? 'Connecting…' : 'Idle')} + }` + ) + + expect(reports.map((report) => report.text)).toEqual(['Connecting…', 'Idle']) + }) + + it('sees copy nested inside a conditional JSX guard', () => { + const reports = candidates( + 'Sample.tsx', + `export function Sample({ show }) { + return
{show &&
+ }` + ) + + expect(reports.map((report) => report.text)).toEqual(['Retry the sync']) + }) + + it('ignores literals used as comparison operands', () => { + const reports = candidates( + 'Sample.tsx', + `export function Sample({ phase }) { + return {phase === 'workspace conflict' ? phase : null} + }` + ) + + expect(reports).toEqual([]) + }) +}) diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index f39015796a9..725f17fdf4c 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -28,7 +28,11 @@ describe('computer-use modifier safety', () => { ) expect(mouseInput).toContain('event.flags = flags') - expect(clickInput.match(/flags: flags/gu)).toHaveLength(3) + // Every click event flows through the shared delivery plan and carries + // the modifier flags on the mouse event itself. + expect(clickInput).toContain('SyntheticMouseClickDelivery.deliver(') + expect(clickInput).toContain('currentSyntheticClickRecipient(') + expect(clickInput).toContain('event.flags = flags') expect(clickInput).not.toContain('down: true') }) diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs index 4ed155e8c5f..22c78a21cc2 100644 --- a/config/scripts/daemon-boot-smoke.mjs +++ b/config/scripts/daemon-boot-smoke.mjs @@ -18,7 +18,7 @@ import { fork } from 'node:child_process' import { connect } from 'node:net' import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) { return join(userDataDir, 'daemon.sock') } -// Best-effort end-to-end PTY check over the daemon's own control-socket RPC. -// Connects a single control socket, completes the hello handshake, and calls -// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves -// true on success, false on any failure — never throws. -function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { - return new Promise((resolveCheck) => { +function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) { + return new Promise((resolveRpc, rejectRpc) => { let settled = false let buffer = '' const socket = connect(socketPath) - const finish = (ok, reason) => { + const finish = (error, response) => { if (settled) { return } settled = true clearTimeout(timer) socket.destroy() - if (!ok && reason) { - log(`PTY spawn health check skipped (best-effort): ${reason}`) + if (error) { + rejectRpc(error) + } else { + resolveRpc(response) } - resolveCheck(ok) } - const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS) + const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs) - socket.on('error', (err) => finish(false, err.message)) + socket.on('error', (error) => finish(error)) socket.on('connect', () => { const token = readFileSync(tokenPath, 'utf8').trim() socket.write( @@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { try { msg = JSON.parse(line) } catch { - finish(false, 'invalid response line') + finish(new Error('invalid response line')) return } if (msg.type === 'hello') { if (!msg.ok) { - finish(false, `hello rejected: ${msg.error ?? 'unknown'}`) + finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`)) return } - socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`) - } else if (msg.id === 'health-1') { + socket.write(`${JSON.stringify(request)}\n`) + } else if (msg.id === request.id) { finish( - msg.ok === true, - msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed') + msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`), + msg ) return } @@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { }) } +// Best-effort: constrained CI runners can make node-pty spawn flaky. +async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { + try { + await runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { id: 'health-1', type: 'ptySpawnHealth' }, + PTY_HEALTH_TIMEOUT_MS + ) + return true + } catch (error) { + log(`PTY spawn health check skipped (best-effort): ${error.message}`) + return false + } +} + async function main() { const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-')) const socketPath = makeSocketPath(userDataDir) const tokenPath = join(userDataDir, 'daemon.token') + const pidPath = join(userDataDir, 'daemon.pid') + const launchNonce = randomUUID() const protocolVersion = readProtocolVersion() log(`forking ${entryPath} under plain Node (${process.execPath})`) - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in - // CI, and this is exactly the runtime where a leaked `require("electron")` - // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } - }) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + '--entry-path', + entryPath, + '--app-version', + 'daemon-boot-smoke' + ], + { + // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in + // CI, and this is exactly the runtime where a leaked `require("electron")` + // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } + } + ) let stderr = '' child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8') }) - child.stdout?.on('data', (chunk) => { - process.stdout.write(chunk) - }) const cleanup = () => { if (child.exitCode === null && child.signalCode === null && child.pid) { @@ -185,6 +215,25 @@ async function main() { }) }) log('daemon signaled ready') + const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8')) + if ( + pidRecord.pid !== child.pid || + pidRecord.launchNonce !== launchNonce || + pidRecord.entryPath !== entryPath || + pidRecord.appVersion !== 'daemon-boot-smoke' + ) { + throw new Error('daemon readiness did not publish the expected PID ownership record') + } + log('PID ownership record matches the ready daemon') + if (!existsSync(socketPath)) { + throw new Error('daemon did not publish its endpoint at the canonical socket path') + } + log('endpoint published at the canonical socket path') + // Production releases startup-only handles after ready; they can pin the child on Windows. + // Diagnostics past this point therefore carry the tail captured up to readiness only. + child.stderr?.destroy() + stderr += '[boot-smoke] stderr released at readiness, mirroring production\n' + child.disconnect() const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) if (ptyHealthy) { @@ -193,18 +242,39 @@ async function main() { await new Promise((resolveExit, rejectExit) => { const timer = setTimeout(() => { - rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)) + rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`)) }, SHUTDOWN_TIMEOUT_MS) child.on('exit', (code, signal) => { clearTimeout(timer) - log(`daemon exited after signal (code=${code}, signal=${signal})`) + log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`) resolveExit() }) - // Why: SIGTERM is the graceful stop on POSIX (the daemon handles it); - // Windows has no POSIX signal delivery, so Node maps this to process - // termination. Either way the hard assertion is "it stops, no hang". - child.kill('SIGTERM') + void runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { + id: 'shutdown-1', + type: 'shutdown', + payload: { killSessions: false } + }, + SHUTDOWN_TIMEOUT_MS + ).catch((error) => { + clearTimeout(timer) + rejectExit(error) + }) }) + if (existsSync(pidPath)) { + throw new Error('daemon left its PID ownership record behind after shutdown') + } + if (process.platform !== 'win32' && existsSync(socketPath)) { + throw new Error('daemon left its endpoint socket behind after shutdown') + } + // The private bind name is consumed by the publish; nothing may linger in the runtime dir. + const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b')) + if (leaked.length > 0) { + throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`) + } log('PASS: daemon booted, served, and shut down under plain Node') } finally { diff --git a/config/scripts/daemon-endpoint-handover-smoke.mjs b/config/scripts/daemon-endpoint-handover-smoke.mjs new file mode 100644 index 00000000000..b6c39a5b55f --- /dev/null +++ b/config/scripts/daemon-endpoint-handover-smoke.mjs @@ -0,0 +1,164 @@ +/** + * Endpoint handover smoke — guards the split-brain failure with real daemon processes. + * + * The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still + * alive used to delete the *replacement's* socket when it finally exited, because libuv + * unlinks the pathname a server bound to with no ownership check. The replacement stayed + * alive hosting PTYs that nothing could reach — terminals that acknowledge input and never + * run it, and that a user cannot fix by restarting the app. + * + * Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur. + * + * Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs + */ +import { fork } from 'node:child_process' +import { connect } from 'node:net' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const repoRoot = resolve(import.meta.dirname, '..', '..') +const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js') +const READY_TIMEOUT_MS = 20_000 +const EXIT_TIMEOUT_MS = 15_000 + +const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`) + +function bootDaemon(tag, dir, socketPath) { + const tokenPath = join(dir, `${tag}.token`) + const pidPath = join(dir, `${tag}.pid`) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + randomUUID(), + '--entry-path', + entryPath, + '--app-version', + 'endpoint-handover-smoke' + ], + { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: dir } + } + ) + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString('utf8') + }) + return new Promise((resolveReady, rejectReady) => { + const timer = setTimeout( + () => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)), + READY_TIMEOUT_MS + ) + child.on('message', (msg) => { + if (msg && typeof msg === 'object' && msg.type === 'ready') { + clearTimeout(timer) + resolveReady({ child, tokenPath, pidPath }) + } + }) + child.on('exit', (code) => { + clearTimeout(timer) + rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`)) + }) + }) +} + +function isReachable(socketPath) { + return new Promise((resolveReachable) => { + const socket = connect({ path: socketPath }) + socket.on('connect', () => { + socket.destroy() + resolveReachable(true) + }) + socket.on('error', () => resolveReachable(false)) + }) +} + +function killAndWait(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve() + } + return new Promise((resolveExit, rejectExit) => { + const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS) + child.on('exit', () => { + clearTimeout(timer) + resolveExit() + }) + child.kill('SIGTERM') + }) +} + +async function main() { + if (process.platform === 'win32') { + log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur') + return + } + if (!existsSync(entryPath)) { + throw new Error(`missing ${entryPath} — run \`pnpm build\` first`) + } + + const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-')) + const socketPath = join(dir, 'daemon.sock') + let replaced + let replacement + try { + replaced = await bootDaemon('replaced', dir, socketPath) + const replacedInode = statSync(socketPath).ino + log('daemon A published the endpoint') + + // Reclaim the endpoint name the way daemon replacement does, while A is still alive. + unlinkSync(socketPath) + replacement = await bootDaemon('replacement', dir, socketPath) + const replacementInode = statSync(socketPath).ino + if (replacedInode === replacementInode) { + throw new Error('daemon B did not publish a distinct endpoint') + } + if (!(await isReachable(socketPath))) { + throw new Error('daemon B is not reachable through the canonical endpoint') + } + log('daemon B took over the endpoint and is reachable') + + // A exits long after losing the endpoint. This is the step that used to break B. + await killAndWait(replaced.child) + await new Promise((r) => setTimeout(r, 300)) + + if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) { + throw new Error("daemon A's late exit deleted daemon B's endpoint") + } + if (!(await isReachable(socketPath))) { + throw new Error('daemon B became unreachable after daemon A exited') + } + if (!existsSync(replacement.pidPath)) { + throw new Error("daemon A's late exit removed daemon B's ownership record") + } + if (existsSync(replaced.pidPath)) { + throw new Error('daemon A left its own ownership record behind') + } + + log('PASS: the endpoint owner and the session host stayed the same daemon') + } finally { + for (const daemon of [replaced, replacement]) { + if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) { + try { + daemon.child.kill('SIGKILL') + } catch { + // already gone + } + } + } + rmSync(dir, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`) + process.exit(1) +}) diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index e869f652e40..080cb4fa785 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -275,6 +275,16 @@ describe('orchestration skill guidance', () => { ) }) + it('documents per-invocation model and effort for supervised workers', () => { + const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop') + + expect(workerLoop).toContain('opaque provider model id with `--model`') + expect(workerLoop).toContain('`--effort` requires `--model`') + expect(workerLoop).toContain('neither option can combine with `--terminal`') + expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json') + expect(workerLoop).toContain('`launch.requested` and `launch.effective`') + }) + it('never authorizes release from idle, timeout, or worker-side triggers', () => { const skill = readSkill() const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop') diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index 12e36f72490..b27a1f945d9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error( - `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` - ) + new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) ) } proc.stdout.on('data', onData) @@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil( - () => messages.slice(startIndex).find(predicate), - label, - streams.stderr - ) + pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) const request = async (method, params = {}) => { const id = nextSequence++ @@ -317,8 +311,10 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid( + pidFile, + undefined, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -330,8 +326,10 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid( + pidFile, + firstWatcherPid, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/config/scripts/terminal-reattach-payload-scan-benchmark.mjs b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs new file mode 100644 index 00000000000..fedfdc2c699 --- /dev/null +++ b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node +// Benchmark: the renderer-side scans that run over a whole reattach payload. +// +// 1. hasCursorAgentReattachPayloadScreenSignal — compares the old hand-rolled char-by-char +// CSI strip against the shipped shape (256KB tail + shared CSI_SEQUENCE_PATTERN). Every +// variant is asserted to agree with the baseline before it is timed. +// 2. TerminalKittyKeyboardModeTracker.scanReplay — measured to justify leaving it alone, and +// to record that porting the daemon mouse mirror's includes() pre-filter makes it slower. +// +// Payloads are generated deterministically (LCG, no Math.random) so runs compare. +// +// Run with: node config/scripts/terminal-reattach-payload-scan-benchmark.mjs +import { performance } from 'node:perf_hooks' +import { CSI_SEQUENCE_PATTERN } from '../../src/shared/ansi-escape-sequences.ts' +import { TerminalKittyKeyboardModeTracker } from '../../src/shared/terminal-kitty-keyboard-mode-tracker.ts' + +const ROUNDS = Number(process.env.ORCA_REATTACH_SCAN_BENCH_ROUNDS ?? '7') +const MIN_ITERATION_MS = 120 + +// --- payload generation ----------------------------------------------------- + +function makeRng(seed) { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +const WORDS = [ + 'src', + 'renderer', + 'components', + 'terminal', + 'pane', + 'connection', + 'reattach', + 'payload', + 'snapshot', + 'daemon', + 'passed', + 'failed', + 'warning', + 'building', + 'index.ts', + '1.24s', + 'ok' +] + +// Heavily SGR-colored scrollback, ~120 cols/line — what a serialized daemon snapshot +// of a colorized TUI/build log looks like. +function buildColoredScrollback(targetBytes, seed) { + const rng = makeRng(seed) + const lines = [] + let size = 0 + while (size < targetBytes) { + let line = '' + let visible = 0 + while (visible < 118) { + const word = WORDS[Math.floor(rng() * WORDS.length)] + const color = 16 + Math.floor(rng() * 200) + line += `\x1b[38;5;${color}m${word}\x1b[0m ` + visible += word.length + 1 + } + lines.push(line) + size += line.length + 1 + } + return lines.join('\r\n') +} + +// Live TUIs also emit cursor moves / erases; keep some of those in the mix. +function withCursorTraffic(body, seed) { + const rng = makeRng(seed) + return body + .split('\r\n') + .map((line, i) => `\x1b[${(i % 40) + 1};1H\x1b[K${line}${rng() < 0.1 ? '\x1b[?25l' : ''}`) + .join('\r\n') +} + +const CURSOR_AGENT_SCREEN = [ + '\x1b[38;5;39mCursor Agent\x1b[0m', + '', + ' \x1b[2mReady\x1b[0m', + '', + '\x1b[38;5;245m→ \x1b[0m' +].join('\r\n') + +function buildPayloads() { + const base200k = buildColoredScrollback(200 * 1024, 1) + const base2m = buildColoredScrollback(2 * 1024 * 1024, 2) + const relayTail = buildColoredScrollback(100 * 1024, 3) + return [ + { name: '200KB snapshot, header hit (tail)', data: `${base200k}\r\n${CURSOR_AGENT_SCREEN}` }, + { name: '200KB snapshot, no header (miss)', data: base200k }, + { name: '2MB snapshot, header hit (tail)', data: `${base2m}\r\n${CURSOR_AGENT_SCREEN}` }, + { name: '2MB snapshot, no header (miss)', data: base2m }, + { name: '100KB relay tail, cursor traffic', data: withCursorTraffic(relayTail, 4) } + ] +} + +// --- strip variants --------------------------------------------------------- + +// Baseline: verbatim copy of pty-connection.ts:448 (not exported). +function stripBaseline(data) { + let normalized = '' + let index = 0 + while (index < data.length) { + if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') { + index += 2 + while (index < data.length) { + const code = data.charCodeAt(index) + index += 1 + if (code >= 0x40 && code <= 0x7e) { + break + } + } + continue + } + normalized += data[index] + index += 1 + } + return normalized +} + +const stripRegex = (data) => data.replace(CSI_SEQUENCE_PATTERN, '') + +const HEADER = 'Cursor Agent' +const TAIL_CHARS = 5000 +// Mirrors CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS in pty-connection.ts. +const SUFFIX_RAW_CHARS = 256 * 1024 + +const signalBaseline = (data) => hasSignal(stripBaseline(data)) +const signalRegex = (data) => hasSignal(stripRegex(data)) +// The shipped shape. +const signalSuffixRegex = (data) => + hasSignal(stripRegex(data.length > SUFFIX_RAW_CHARS ? data.slice(-SUFFIX_RAW_CHARS) : data)) + +function hasSignal(normalized) { + const headerIndex = normalized.lastIndexOf(HEADER) + if (headerIndex === -1) { + return false + } + return normalized.slice(headerIndex + HEADER.length, headerIndex + TAIL_CHARS).includes(`${'→'} `) +} + +// --- timing ----------------------------------------------------------------- + +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + const mid = sorted.length >> 1 + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 +} + +function timeMsPerCall(fn, arg) { + let iterations = 1 + while (true) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + globalThis.__sink = fn(arg) + } + const elapsed = performance.now() - start + if (elapsed >= MIN_ITERATION_MS || iterations >= 1 << 22) { + return elapsed / iterations + } + iterations *= Math.max(2, Math.ceil(MIN_ITERATION_MS / Math.max(elapsed, 0.01))) + } +} + +function measure(fn, arg) { + timeMsPerCall(fn, arg) // warmup / JIT + const samples = [] + for (let round = 0; round < ROUNDS; round++) { + samples.push(timeMsPerCall(fn, arg)) + } + return median(samples) +} + +const pad = (value, width) => String(value).padStart(width) +const kb = (data) => `${(data.length / 1024).toFixed(0)}KB` + +// --- run -------------------------------------------------------------------- + +const payloads = buildPayloads() + +console.log('Reattach payload scans, ms/call (median of %d rounds). Lower is better.\n', ROUNDS) +console.log('== hasCursorAgentReattachPayloadScreenSignal ==') +console.log( + `${pad('payload', 36)} ${pad('size', 8)} ${pad('char-loop', 11)} ${pad('regex', 10)} ${pad('suffix+regex', 13)} ${pad('speedup', 9)}` +) +for (const { name, data } of payloads) { + const expected = signalBaseline(data) + for (const [label, fn] of Object.entries({ + regex: signalRegex, + 'suffix+regex': signalSuffixRegex + })) { + if (fn(data) !== expected) { + throw new Error(`${label} disagrees with baseline on "${name}"`) + } + } + const base = measure(signalBaseline, data) + const re = measure(signalRegex, data) + const suffix = measure(signalSuffixRegex, data) + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(base.toFixed(3), 11)} ${pad(re.toFixed(3), 10)} ${pad(suffix.toFixed(3), 13)} ${pad(`${(base / suffix).toFixed(0)}x`, 9)}` + ) +} + +console.log('\n== TerminalKittyKeyboardModeTracker.scanReplay ==') +console.log( + `${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}` +) +const kittyScan = (data) => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scanReplay(data) + return tracker.flags +} +// Mirrors src/main/daemon/terminal-mouse-mode-mirror.ts:41-47. +const kittyGated = (data) => { + if (!data.includes('\x1b[?') && !data.includes('\x1bc') && !data.includes('\x9b')) { + return 0 + } + return kittyScan(data) +} +for (const { name, data } of payloads) { + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}` + ) +} + +// The gate can only pay off where no introducer exists: live plain-output chunks, not +// snapshots (which carry ?1049h/?25l by construction). +const liveChunk = buildColoredScrollback(4 * 1024, 5).replaceAll('\x1b', '') +const introducerRows = [ + ['4KB live chunk, no escapes', liveChunk], + ['4KB live chunk, \\x1b[?25l present', `${liveChunk}\x1b[?25l`] +] +console.log( + `\n${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}` +) +for (const [name, data] of introducerRows) { + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}` + ) +} + +console.log( + '\nSnapshot/replay payloads always contain \\x1b[?, so the includes() gate never fires on\nthe reattach path — it only helps the live per-chunk path.' +) + +// Reference point: xterm parses the same bytes right after these scans run, so its cost +// is the yardstick for whether the scans are worth cutting. +const xtermHeadless = await import('@xterm/headless') +const Terminal = xtermHeadless.Terminal ?? xtermHeadless.default.Terminal +const writeToXterm = (data) => + new Promise((resolve) => { + const terminal = new Terminal({ cols: 120, rows: 40, scrollback: 5000, allowProposedApi: true }) + const start = performance.now() + terminal.write(data, () => { + const elapsed = performance.now() - start + terminal.dispose() + resolve(elapsed) + }) + }) + +console.log(`\n== xterm headless parse of the same payload (reference) ==`) +console.log(`${pad('payload', 36)} ${pad('size', 8)} ${pad('xterm write', 11)}`) +for (const { name, data } of payloads) { + await writeToXterm(data) + const samples = [] + for (let round = 0; round < ROUNDS; round++) { + samples.push(await writeToXterm(data)) + } + console.log(`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(median(samples).toFixed(3), 11)}`) +} diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4247281300..386fb6a1594 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -63,6 +63,7 @@ "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", + "../src/main/grok/windows-grok-hook-script.ts", "../src/main/devin/hook-settings.ts", "../src/main/devin/hook-service.ts", "../src/main/devin/hook-config-json.ts", @@ -72,6 +73,9 @@ "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", + // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this + // project's serve spec; the module has no imports, so listing it pulls in nothing else. + "../src/main/startup/serve-mode-argv.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/win32-utils.ts" ], diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 7c4bb7c359e..f576fa77dc0 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -16,6 +16,7 @@ "../src/main/wsl-distro-retry.ts", "../src/main/wsl.ts", "../src/main/startup/serve-desktop-activation.ts", + "../src/main/startup/serve-mode-argv.ts", "../src/main/startup/single-instance-lock.ts", "../src/main/startup/startup-diagnostics.ts", "../src/main/window/focus-existing-window.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 872ae567b24..426e6249764 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 15m + + downloads: 16m @@ -15,7 +15,7 @@ downloads downloads - 15m - 15m + 16m + 16m diff --git a/mobile/app.json b/mobile/app.json index 53654335486..87b50446486 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.37", + "version": "0.0.41", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 10 + "versionCode": 11 }, "plugins": [ "expo-router", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 1292b6e5f5f..9080cdedcf9 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -10,7 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' -import { loadHosts } from '../src/transport/host-store' +import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' @@ -95,9 +95,12 @@ export default function RootLayout() { } async function getNavigationTarget(data: unknown) { - const hosts = await loadHosts().catch(() => null) + const hosts = await loadHostCatalog().catch(() => null) return getNotificationNavigationTarget(data, { - knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined + knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined, + credentialStatusByHostId: hosts + ? new Map(hosts.map((host) => [host.id, host.credentialStatus])) + : undefined }) } diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index debd57a924f..a6660ffe721 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -67,6 +67,7 @@ import { leaveHostRoute } from '../../../src/host-route-exit' import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences' import { createInitialHostRouteActionState, + hostNewWorktreeSessionRoute, resolveHostRouteActionState, setHostRouteNewWorktreeVisible } from '../../../src/host-route-action-state' @@ -1403,10 +1404,7 @@ export function HostScreen({ }} onCreated={(worktreeId, worktreeName) => { void fetchWorktrees({ allowDuringModal: true }) - const params = new URLSearchParams({ name: worktreeName, created: '1' }) - navigateFromHostList( - `/h/${hostId}/session/${encodeURIComponent(worktreeId)}?${params.toString()}` - ) + navigateFromHostList(hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName)) }} onRouteVisibleChange={setShowNewWorktreeVisible} /> diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c5e4175acc0..755d6605501 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4986,6 +4986,7 @@ export default function SessionScreen() { ([]) + const [hostCatalog, setHostCatalog] = useState([]) const [actionTarget, setActionTarget] = useState(null) - const [confirmRemove, setConfirmRemove] = useState(null) + const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) @@ -242,10 +241,15 @@ export default function HomeScreen() { const onboardingOptInCheckedRef = useRef(false) // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. + const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog]) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) - const allClients = useAllHostClients(hostIds) + const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) + const allClients = useAllHostClients(hostIds, { + autoConnectHostIds, + closeUnusedOnRelease: true + }) const hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), [allClients] @@ -308,12 +312,12 @@ export default function HomeScreen() { useFocusEffect( useCallback(() => { let stale = false - void loadHosts().then(async (h) => { + void loadHostCatalog().then(async (catalog) => { if (stale) { return } - setHosts(h) - if (h.length === 0 || onboardingOptInCheckedRef.current) { + setHostCatalog(catalog) + if (catalog.length === 0 || onboardingOptInCheckedRef.current) { return } onboardingOptInCheckedRef.current = true @@ -351,6 +355,10 @@ export default function HomeScreen() { () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected), [hosts] ) + const sortedHostCatalog = useMemo( + () => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected), + [hostCatalog] + ) // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working. useEffect(() => { @@ -389,17 +397,24 @@ export default function HomeScreen() { } } // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'. - for (const host of hosts) { + for (const host of hostCatalog) { if (liveIds.has(host.id)) { continue } - if (!host.publicKeyB64 || !host.deviceToken) { + if (host.credentialStatus === 'missing') { if (next[host.id] !== 'auth-failed') { next[host.id] = 'auth-failed' changed = true } continue } + if (host.credentialStatus === 'temporarily-unavailable') { + if (next[host.id] !== 'disconnected') { + next[host.id] = 'disconnected' + changed = true + } + continue + } const prevState = next[host.id] if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') { next[host.id] = 'disconnected' @@ -408,14 +423,14 @@ export default function HomeScreen() { } // Drop entries for hosts we no longer track at all. for (const id of Object.keys(next)) { - if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) { + if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) { delete next[id] changed = true } } return changed ? next : prev }) - }, [allClients, hosts]) + }, [allClients, hostCatalog]) // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the @@ -551,10 +566,11 @@ export default function HomeScreen() { return items }, [sortedHosts, hostStates, accountsByHost]) - const primaryConnectedHost = useMemo( - () => sortedHosts.find((host) => hostStates[host.id] === 'connected') ?? null, + const connectedHosts = useMemo( + () => sortedHosts.filter((host) => hostStates[host.id] === 'connected'), [sortedHosts, hostStates] ) + const primaryConnectedHost = connectedHosts[0] ?? null const primaryTaskProviders = primaryConnectedHost ? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github']) : [] @@ -629,7 +645,7 @@ export default function HomeScreen() { try { await removeHostAndCloseClient(hostToRemove.id, closeHostClient) setConfirmRemove(null) - setHosts(await loadHosts()) + setHostCatalog(await loadHostCatalog()) } catch { // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent. setConfirmRemove(hostToRemove) @@ -655,7 +671,7 @@ export default function HomeScreen() { - {hosts.length === 0 ? ( + {hostCatalog.length === 0 ? ( /* ─── Empty state: onboarding ─── */ h.id} // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator. contentContainerStyle={[ @@ -732,7 +748,11 @@ export default function HomeScreen() { } ItemSeparatorComponent={CardGap} renderItem={({ item }) => { - const state = hostStates[item.id] ?? 'connecting' + const state = resolveHomeHostConnectionState( + item.id, + hostStates[item.id], + autoConnectHostIds + ) const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null const verdict = classifyConnection({ @@ -744,14 +764,36 @@ export default function HomeScreen() { return ( router.push(`/h/${item.id}`)} + onPress={() => { + if (item.credentialStatus === 'missing') { + router.push('/pair-scan') + } else if (item.credentialStatus === 'temporarily-unavailable') { + void loadHostCatalog() + .then(setHostCatalog) + .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) + } else { + router.push(`/h/${item.id}`) + } + }} onLongPress={() => { triggerMediumImpact() - setActionTarget(item) + if (item.profile) { + setActionTarget(item.profile) + } else { + setConfirmRemove(item) + } + }} + onOpenActions={() => { + if (item.profile) { + setActionTarget(item.profile) + } else { + setConfirmRemove(item) + } }} /> ) @@ -800,36 +842,11 @@ export default function HomeScreen() { {renderTaskHomeCard()} {/* ─── Quick actions ─── */} - Quick Actions - - [styles.quickAction, pressed && styles.hostCardPressed]} - onPress={() => router.push('/pair-scan')} - > - - - - Pair Desktop - - [ - styles.quickAction, - !primaryConnectedHost && styles.cardDisabled, - pressed && styles.hostCardPressed - ]} - onPress={() => { - if (primaryConnectedHost) { - router.push(`/h/${primaryConnectedHost.id}?action=newWorktree`) - } - }} - > - - - - New Workspace - - + router.push('/pair-scan')} + onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))} + /> {/* ─── Account usage ─── */} {accountsHosts.length > 0 ? ( @@ -917,10 +934,16 @@ export default function HomeScreen() { void forceReconnectHost(hostId), onDisconnect: closeHostClient, onEdit: openMobileHostEdit, - onRemove: setConfirmRemove + onRemove: (host) => setConfirmRemove(host) })} onClose={() => setActionTarget(null)} /> @@ -1132,6 +1155,9 @@ const styles = StyleSheet.create({ paddingRight: spacing.md, paddingVertical: 12 }, + cardDisabled: { + opacity: 0.45 + }, taskHomeIcon: { width: 46, height: 46, @@ -1225,40 +1251,6 @@ const styles = StyleSheet.create({ marginTop: 4 }, - /* ─── Quick actions ─── */ - quickActions: { - flexDirection: 'row', - gap: spacing.sm - }, - quickAction: { - flex: 1, - flexDirection: 'row', - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - paddingVertical: 10, - paddingHorizontal: 12, - alignItems: 'center', - gap: 10 - }, - cardDisabled: { - opacity: 0.45 - }, - quickActionIcon: { - width: 28, - height: 28, - borderRadius: 9, - backgroundColor: 'rgba(255,255,255,0.04)', - alignItems: 'center', - justifyContent: 'center' - }, - quickActionLabel: { - fontSize: 12, - fontWeight: '600', - color: colors.textSecondary - }, - /* ─── Empty state ─── */ emptyContainer: { flex: 1 diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index e6aa691c76e..e55819aa22a 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' @@ -127,7 +127,7 @@ export default function TerminalSettingsScreen() { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const hostClients = useAllHostClients(hostIds) + const { clients: hostClients } = useFocusedSettingsHostClients(hostIds) const hostClientsById = useMemo( () => new Map(hostClients.map((entry) => [entry.hostId, entry.client])), [hostClients] diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index a09d964b388..8648a1d38e5 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -9,12 +9,12 @@ import { View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useFocusEffect, useRouter } from 'expo-router' +import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { BottomDrawer } from '../src/components/BottomDrawer' import { VoiceModelList } from '../src/components/VoiceModelList' @@ -47,7 +47,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const hostClients = useAllHostClients(hostIds) + const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds) // Voice dictation runs on the paired desktop, so pick the first connected host. const client: RpcClient | null = useMemo( () => hostClients.find((entry) => entry.state === 'connected')?.client ?? null, @@ -59,15 +59,6 @@ export default function VoiceSettingsScreen(): React.JSX.Element { const [error, setError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [modelDrawerOpen, setModelDrawerOpen] = useState(false) - const [routeFocused, setRouteFocused] = useState(false) - - useFocusEffect( - useCallback(() => { - setRouteFocused(true) - return () => setRouteFocused(false) - }, []) - ) - const refresh = useCallback(async (): Promise => { if (!client) { return false diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index 03893215f2e..f938a98772c 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -45,7 +45,7 @@ describe('mobile accounts route', () => { navigateToHostStackRoute( harness.navigation, - { push }, + { push, replace: vi.fn() }, 'host/one', mobileAccountsRouteTarget('host/one') ) diff --git a/mobile/src/components/MobileHomeQuickActions.test.ts b/mobile/src/components/MobileHomeQuickActions.test.ts new file mode 100644 index 00000000000..ba2e0ba750f --- /dev/null +++ b/mobile/src/components/MobileHomeQuickActions.test.ts @@ -0,0 +1,223 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostProfile } from '../transport/types' +import { MobileHomeQuickActions } from './MobileHomeQuickActions' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Plus: 'Plus', + QrCode: 'QrCode' +})) + +vi.mock('./PickerModal', async () => { + const React = await import('react') + return { + PickerModal: (props: unknown) => React.createElement('PickerModal', props) + } +}) + +function host(id: string, name: string, endpoint: string): HostProfile { + return { + id, + name, + endpoint, + deviceToken: `token-${id}`, + publicKeyB64: `key-${id}`, + lastConnected: 1 + } +} + +describe('MobileHomeQuickActions', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + async function renderQuickActions(connectedHosts: HostProfile[]) { + const onPairDesktop = vi.fn() + const onCreateWorkspace = vi.fn() + const quickActions = (hosts: HostProfile[]) => + createElement(MobileHomeQuickActions, { + connectedHosts: hosts, + onPairDesktop, + onCreateWorkspace + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { + throw new Error(String(args[0])) + } + }) + await act(async () => { + renderer = create(quickActions(connectedHosts)) + }) + consoleError.mockRestore() + return { + onCreateWorkspace, + rerender: async (hosts: HostProfile[]) => { + await act(async () => renderer!.update(quickActions(hosts))) + } + } + } + + function newWorkspaceButton() { + return renderer!.root.findAllByType('Pressable')[1] + } + + function picker() { + return renderer!.root.findByType('PickerModal') + } + + it('disables workspace creation without a connected host', async () => { + await renderQuickActions([]) + + expect(newWorkspaceButton().props.disabled).toBe(true) + expect(newWorkspaceButton().props.accessibilityState).toEqual({ disabled: true }) + expect(picker().props.visible).toBe(false) + }) + + it('opens the only connected host directly', async () => { + const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768') + const callbacks = await renderQuickActions([desk]) + + act(() => newWorkspaceButton().props.onPress()) + + expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('desk') + expect(picker().props.visible).toBe(false) + }) + + it('asks which host to use when multiple are connected', async () => { + const callbacks = await renderQuickActions([ + host('desk', 'Desk', 'ws://192.168.1.2:6768'), + host('laptop', 'Laptop', 'wss://relay.example.com/mobile') + ]) + + act(() => newWorkspaceButton().props.onPress()) + + expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled() + expect(picker().props.visible).toBe(true) + expect(picker().props.title).toBe('Create Workspace On') + expect(picker().props.options).toEqual([ + { value: 'desk', label: 'Desk', subtitle: '192.168.1.2:6768' }, + { value: 'laptop', label: 'Laptop', subtitle: 'relay.example.com' } + ]) + + act(() => picker().props.onSelect('laptop')) + expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled() + expect(picker().props.visible).toBe(false) + + act(() => picker().props.onAfterClose()) + expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('laptop') + }) + + it('disambiguates path-routed hosts without exposing endpoint paths', async () => { + await renderQuickActions([ + host('desk-a', 'Desk', 'wss://gateway.example.com/v1/connect/bearer-secret-a'), + host('desk-b', 'Desk', 'wss://gateway.example.com/v1/connect/bearer-secret-b') + ]) + + act(() => newWorkspaceButton().props.onPress()) + + expect(picker().props.options).toEqual([ + { + value: 'desk-a', + label: 'Desk', + subtitle: 'gateway.example.com · desk-a' + }, + { + value: 'desk-b', + label: 'Desk', + subtitle: 'gateway.example.com · desk-b' + } + ]) + expect(JSON.stringify(picker().props.options)).not.toContain('bearer-secret') + }) + + it('does not expose malformed legacy endpoint details', async () => { + await renderQuickActions([ + host('desk-a', 'Desk', 'gateway.example.com/v1/connect/bearer-secret?token=query-secret'), + host('desk-b', 'Desk', 'localhost:6768/private-secret') + ]) + + act(() => newWorkspaceButton().props.onPress()) + + expect(picker().props.options).toEqual([ + { value: 'desk-a', label: 'Desk', subtitle: 'Unknown endpoint · desk-a' }, + { value: 'desk-b', label: 'Desk', subtitle: 'Unknown endpoint · desk-b' } + ]) + expect(JSON.stringify(picker().props.options)).not.toContain('secret') + }) + + it('closes a stale picker when fewer than two hosts remain connected', async () => { + const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768') + const laptop = host('laptop', 'Laptop', 'wss://relay.example.com/mobile') + const callbacks = await renderQuickActions([desk, laptop]) + + act(() => newWorkspaceButton().props.onPress()) + expect(picker().props.visible).toBe(true) + + await callbacks.rerender([desk]) + expect(picker().props.visible).toBe(false) + act(() => picker().props.onAfterClose()) + + await callbacks.rerender([desk, laptop]) + expect(picker().props.visible).toBe(false) + }) + + it('does not reopen a stale picker if its old host set returns while closing', async () => { + const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768') + const laptop = host('laptop', 'Laptop', 'wss://relay.example.com/mobile') + const callbacks = await renderQuickActions([desk, laptop]) + + act(() => newWorkspaceButton().props.onPress()) + await callbacks.rerender([desk]) + await callbacks.rerender([desk, laptop]) + + expect(picker().props.visible).toBe(false) + act(() => picker().props.onAfterClose()) + expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled() + }) + + it('keeps a selected host through an unrelated topology change while closing', async () => { + const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768') + const laptop = host('laptop', 'Laptop', 'wss://relay.example.com/mobile') + const callbacks = await renderQuickActions([desk, laptop]) + + act(() => newWorkspaceButton().props.onPress()) + act(() => picker().props.onSelect('laptop')) + await callbacks.rerender([ + desk, + laptop, + host('server', 'Server', 'wss://ssh.example.com/mobile') + ]) + act(() => picker().props.onAfterClose()) + + expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('laptop') + }) + + it('drops a selection that disconnects while the picker is closing', async () => { + const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768') + const laptop = host('laptop', 'Laptop', 'wss://relay.example.com/mobile') + const callbacks = await renderQuickActions([desk, laptop]) + + act(() => newWorkspaceButton().props.onPress()) + act(() => picker().props.onSelect('laptop')) + await callbacks.rerender([desk]) + act(() => picker().props.onAfterClose()) + + expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/components/MobileHomeQuickActions.tsx b/mobile/src/components/MobileHomeQuickActions.tsx new file mode 100644 index 00000000000..9dca88997da --- /dev/null +++ b/mobile/src/components/MobileHomeQuickActions.tsx @@ -0,0 +1,159 @@ +import { useRef, useState } from 'react' +import { Plus, QrCode } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import type { HostProfile } from '../transport/types' +import { hostEndpointLabel } from '../transport/host-endpoint-label' +import { colors, radii, spacing } from '../theme/mobile-theme' +import { PickerModal } from './PickerModal' + +type Props = { + connectedHosts: HostProfile[] + onPairDesktop: () => void + onCreateWorkspace: (hostId: string) => void +} + +function hostPickerOptions(hosts: HostProfile[]) { + const entries = hosts.map((host) => ({ + host, + endpointLabel: hostEndpointLabel(host.endpoint) + })) + const endpointCounts = new Map() + for (const entry of entries) { + const endpointKey = JSON.stringify([entry.host.name, entry.endpointLabel]) + endpointCounts.set(endpointKey, (endpointCounts.get(endpointKey) ?? 0) + 1) + } + return entries.map((entry) => { + const endpointKey = JSON.stringify([entry.host.name, entry.endpointLabel]) + const endpointCollides = (endpointCounts.get(endpointKey) ?? 0) > 1 + const subtitle = endpointCollides + ? `${entry.endpointLabel} · ${entry.host.id}` + : entry.endpointLabel + return { value: entry.host.id, label: entry.host.name, subtitle } + }) +} + +export function MobileHomeQuickActions(props: Props) { + const [hostPickerForHostSet, setHostPickerForHostSet] = useState(null) + const pendingHostIdRef = useRef(null) + const canCreateWorkspace = props.connectedHosts.length > 0 + const hostSetKey = JSON.stringify(props.connectedHosts.map((host) => host.id)) + const hostPickerVisible = hostPickerForHostSet === hostSetKey + if (hostPickerForHostSet !== null && !hostPickerVisible) { + setHostPickerForHostSet(null) + } + + function handleCreateWorkspace() { + if (props.connectedHosts.length === 1) { + props.onCreateWorkspace(props.connectedHosts[0].id) + return + } + if (props.connectedHosts.length > 1) { + setHostPickerForHostSet(hostSetKey) + } + } + + function handleHostSelect(hostId: string) { + pendingHostIdRef.current = hostId + setHostPickerForHostSet(null) + } + + function handleHostPickerClosed() { + setHostPickerForHostSet(null) + const hostId = pendingHostIdRef.current + pendingHostIdRef.current = null + if (hostId && props.connectedHosts.some((host) => host.id === hostId)) { + props.onCreateWorkspace(hostId) + } + } + + return ( + <> + Quick Actions + + [styles.quickAction, pressed && styles.quickActionPressed]} + onPress={props.onPairDesktop} + > + + + + Pair Desktop + + [ + styles.quickAction, + !canCreateWorkspace && styles.quickActionDisabled, + pressed && styles.quickActionPressed + ]} + onPress={handleCreateWorkspace} + > + + + + New Workspace + + + setHostPickerForHostSet(null)} + onAfterClose={handleHostPickerClosed} + /> + + ) +} + +const styles = StyleSheet.create({ + sectionHeading: { + marginTop: spacing.xl, + marginBottom: spacing.sm, + paddingHorizontal: spacing.xs, + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.6 + }, + quickActions: { + flexDirection: 'row', + gap: spacing.sm + }, + quickAction: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + backgroundColor: colors.bgPanel + }, + quickActionPressed: { + backgroundColor: colors.bgRaised + }, + quickActionDisabled: { + opacity: 0.45 + }, + quickActionIcon: { + width: 28, + height: 28, + borderRadius: 9, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised + }, + quickActionLabel: { + color: colors.textSecondary, + fontSize: 12, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/MobileHostCard.test.ts b/mobile/src/components/MobileHostCard.test.ts new file mode 100644 index 00000000000..f7c494cfdbf --- /dev/null +++ b/mobile/src/components/MobileHostCard.test.ts @@ -0,0 +1,203 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileHostCard } from './MobileHostCard' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Monitor: 'Monitor', + MoreVertical: 'MoreVertical' +})) + +vi.mock('./StatusDot', () => ({ + StatusDot: 'StatusDot' +})) + +function suppressRendererDeprecation() { + return vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { + throw new Error(String(args[0])) + } + }) +} + +describe('MobileHostCard', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + it('keeps host navigation and actions as separate accessible controls', async () => { + const onPress = vi.fn() + const onLongPress = vi.fn() + const onOpenActions = vi.fn() + const consoleError = suppressRendererDeprecation() + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host: { + id: 'desk', + name: 'Desk', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 1 + }, + state: 'disconnected', + verdict: { kind: 'normal', label: 'Disconnected' }, + path: 'lan', + onPress, + onLongPress, + onOpenActions + }) + ) + }) + consoleError.mockRestore() + + const buttons = renderer.root.findAllByType('Pressable') + expect(buttons).toHaveLength(2) + expect(buttons[0].props.accessibilityRole).toBe('button') + expect(buttons[0].props.accessibilityLabel).toBe('Open Desk, Disconnected') + expect(buttons[1].props.accessibilityRole).toBe('button') + expect(buttons[1].props.accessibilityLabel).toBe('Actions for Desk') + expect(buttons[1].props.hitSlop).toBe(8) + expect(buttons[1].props.style({ pressed: false })[0]).toMatchObject({ width: 40, height: 40 }) + expect(renderer.root.findAllByType('MoreVertical')).toHaveLength(1) + expect(renderer.root.findAllByType('ChevronRight')).toHaveLength(0) + + act(() => buttons[1].props.onPress()) + expect(onOpenActions).toHaveBeenCalledOnce() + expect(onPress).not.toHaveBeenCalled() + + act(() => buttons[0].props.onPress()) + act(() => buttons[0].props.onLongPress()) + expect(onPress).toHaveBeenCalledOnce() + expect(onLongPress).toHaveBeenCalledOnce() + }) + + it('announces the connection path without the visual separator', async () => { + const consoleError = suppressRendererDeprecation() + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host: { + id: 'desk', + name: 'Desk', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 1 + }, + state: 'connected', + verdict: { kind: 'normal', label: 'Connected' }, + path: 'tailscale', + worktreeInfo: { + hostId: 'desk', + totalWorktrees: 3, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: Date.now() + }, + onPress: vi.fn(), + onLongPress: vi.fn(), + onOpenActions: vi.fn() + }) + ) + }) + consoleError.mockRestore() + + const navigationButton = renderer.root.findAllByType('Pressable')[0] + expect(navigationButton.props.accessibilityLabel).toBe( + 'Open Desk, Connected, Direct via Tailscale, 3 worktrees, 2 active' + ) + }) + + it('preserves the connected worktree-catalog failure state', async () => { + const consoleError = suppressRendererDeprecation() + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host: { + id: 'desk', + name: 'Desk', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 1 + }, + state: 'connected', + verdict: { kind: 'normal', label: 'Connected' }, + path: 'relay', + worktreeInfo: { + hostId: 'desk', + totalWorktrees: 0, + activeCount: 0, + lastActiveWorktree: null, + catalogUnavailable: true + }, + onPress: vi.fn(), + onLongPress: vi.fn(), + onOpenActions: vi.fn() + }) + ) + }) + consoleError.mockRestore() + + const navigationButton = renderer.root.findAllByType('Pressable')[0] + expect(navigationButton.props.accessibilityLabel).toBe( + 'Open Desk, Connected, Orca Relay, Worktree list unavailable' + ) + expect( + renderer.root + .findAllByType('Text') + .some((node) => node.children.includes('Worktree list unavailable')) + ).toBe(true) + }) + + it('includes visible offline recovery guidance in the navigation label', async () => { + const consoleError = suppressRendererDeprecation() + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host: { + id: 'desk', + name: 'Desk', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 1 + }, + state: 'reconnecting', + verdict: { + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'never-connected' + }, + path: 'lan', + onPress: vi.fn(), + onLongPress: vi.fn(), + onOpenActions: vi.fn() + }) + ) + }) + consoleError.mockRestore() + + const navigationButton = renderer.root.findAllByType('Pressable')[0] + expect(navigationButton.props.accessibilityLabel).toBe( + "Open Desk, Can't reach desktop, Update desktop Orca and sign in to connect from anywhere" + ) + }) +}) diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx index 6138e311127..66e978a14ce 100644 --- a/mobile/src/components/MobileHostCard.test.tsx +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectionVerdict } from '../transport/connection-health' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' -import type { ConnectionState, HostProfile } from '../transport/types' +import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types' import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo @@ -16,7 +16,7 @@ vi.mock('react-native', () => ({ Text: 'Text', View: 'View' })) -vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' })) +vi.mock('lucide-react-native', () => ({ Monitor: 'Monitor', MoreVertical: 'MoreVertical' })) vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' })) const host: HostProfile = { @@ -54,6 +54,7 @@ describe('MobileHostCard', () => { state?: ConnectionState verdict?: ConnectionVerdict path?: MobileConnectionPath + credentialStatus?: HostCredentialStatus } ): Promise { await act(async () => { @@ -63,9 +64,11 @@ describe('MobileHostCard', () => { state: overrides?.state ?? 'connected', verdict: overrides?.verdict ?? verdict, path: overrides?.path ?? 'lan', + credentialStatus: overrides?.credentialStatus, worktreeInfo, onPress: () => {}, - onLongPress: () => {} + onLongPress: () => {}, + onOpenActions: () => {} }) ) }) @@ -139,4 +142,28 @@ describe('MobileHostCard', () => { expect(lines).not.toContain('0 worktrees') expect(lines).not.toContain('Worktree list unavailable') }) + + it('offers re-pairing when the credential is missing', async () => { + const lines = await renderCard(loaded, { + state: 'connected', + verdict: { kind: 'auth-failed', label: 'Pairing invalid' }, + credentialStatus: 'missing' + }) + + expect(lines).toContain('Pairing invalid') + expect(lines).toContain('Tap to re-pair with your desktop') + expect(lines).not.toContain('12 worktrees · 2 active') + }) + + it('offers a retry without declaring a transient read failure invalid', async () => { + const lines = await renderCard(undefined, { + state: 'disconnected', + verdict: { kind: 'normal', label: 'Disconnected' }, + credentialStatus: 'temporarily-unavailable' + }) + + expect(lines).toContain('Pairing temporarily unavailable') + expect(lines).toContain('Unlock your phone, then tap to retry') + expect(lines).not.toContain('Pairing invalid') + }) }) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index f81f936405a..a09ae0f3cf7 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -1,16 +1,17 @@ -import { ChevronRight, Monitor } from 'lucide-react-native' +import { Monitor, MoreVertical } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' import type { ConnectionVerdict } from '../transport/connection-health' import { verdictDisplayLabel } from '../transport/connection-health' import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' -import type { ConnectionState, HostProfile } from '../transport/types' +import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types' import { colors, radii, spacing } from '../theme/mobile-theme' import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info' import { StatusDot } from './StatusDot' export function MobileHostCard(props: { - host: HostProfile + host: HostProfile | HostCatalogEntry + credentialStatus?: HostCatalogEntry['credentialStatus'] state: ConnectionState verdict: ConnectionVerdict path: MobileConnectionPath @@ -19,52 +20,113 @@ export function MobileHostCard(props: { worktreeInfo?: HostWorktreeInfo onPress: () => void onLongPress: () => void + onOpenActions: () => void }) { - const connected = props.state === 'connected' + const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable' + const credentialMissing = props.credentialStatus === 'missing' + const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing // Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the // path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is // named — 'lan' doubles as the unknown-path default, so it would be a guess before connect. const dialingPath = ['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay' - const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + const isError = + credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + const statusLabel = credentialMissing + ? 'Pairing invalid' + : credentialUnavailable + ? 'Pairing temporarily unavailable' + : verdictDisplayLabel(props.verdict) + const statusVerdict: ConnectionVerdict = credentialMissing + ? { kind: 'auth-failed', label: statusLabel } + : credentialUnavailable + ? { kind: 'warning', label: statusLabel } + : props.verdict const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) + const connectionPathLabel = + !credentialMissing && !credentialUnavailable && (connected || dialingPath) + ? mobileConnectionPathLabel(props.path) + : null + const discoveryHint = + props.verdict.kind === 'unreachable' && !props.host.relay + ? 'Update desktop Orca and sign in to connect from anywhere' + : null + const credentialHint = credentialMissing + ? 'Tap to re-pair with your desktop' + : credentialUnavailable + ? 'Unlock your phone, then tap to retry' + : null + const accessibilityLabel = [ + `Open ${props.host.name}`, + statusLabel, + connectionPathLabel?.replace(' · ', ' via '), + connected ? worktreeSummary?.replace(' · ', ', ') : null, + discoveryHint, + credentialHint + ] + .filter(Boolean) + .join(', ') return ( - [styles.card, pressed && styles.cardPressed]} - onPress={props.onPress} - onLongPress={props.onLongPress} - delayLongPress={400} - > - - - - - - {props.host.name} - - - - - {verdictDisplayLabel(props.verdict)} - {connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''} - + + [styles.cardMain, pressed && styles.cardPressed]} + onPress={props.onPress} + onLongPress={props.onLongPress} + delayLongPress={400} + > + + - {connected && worktreeSummary ? ( - - {worktreeSummary} + + + {props.host.name} - ) : null} - {props.verdict.kind === 'unreachable' && !props.host.relay ? ( - - Update desktop Orca and sign in to connect from anywhere - - ) : null} - - - + + + + {statusLabel} + {connectionPathLabel ? ` · ${connectionPathLabel}` : ''} + + + {connected && worktreeSummary ? ( + + {worktreeSummary} + + ) : null} + {discoveryHint ? ( + + {discoveryHint} + + ) : null} + {credentialHint ? ( + + {credentialHint} + + ) : null} + + + [styles.actionButton, pressed && styles.actionButtonPressed]} + onPress={props.onOpenActions} + > + + + ) } @@ -72,12 +134,19 @@ const styles = StyleSheet.create({ card: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: 12, borderRadius: radii.card, backgroundColor: colors.bgPanel, borderWidth: 1, - borderColor: colors.borderSubtle + borderColor: colors.borderSubtle, + overflow: 'hidden' + }, + cardMain: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: spacing.md, + paddingVertical: 12 }, cardPressed: { backgroundColor: colors.bgRaised }, icon: { @@ -104,5 +173,16 @@ const styles = StyleSheet.create({ fontSize: 11, lineHeight: 15, color: colors.textMuted + }, + actionButton: { + width: 40, + height: 40, + marginHorizontal: spacing.xs, + borderRadius: radii.row, + alignItems: 'center', + justifyContent: 'center' + }, + actionButtonPressed: { + backgroundColor: colors.bgRaised } }) diff --git a/mobile/src/components/PickerModal.accessibility.test.ts b/mobile/src/components/PickerModal.accessibility.test.ts new file mode 100644 index 00000000000..4508f227554 --- /dev/null +++ b/mobile/src/components/PickerModal.accessibility.test.ts @@ -0,0 +1,66 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PickerModal } from './PickerModal' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ Check: 'Check' })) + +vi.mock('./BottomDrawer', async () => { + const React = await import('react') + return { + BottomDrawer: ({ children }: { children?: ReactNode }) => + React.createElement('BottomDrawer', null, children) + } +}) + +describe('PickerModal accessibility', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { + throw new Error(String(args[0])) + } + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + it('announces option rows as actionable with their selection and disabled state', async () => { + await act(async () => { + renderer = create( + createElement(PickerModal, { + visible: true, + title: 'Create Workspace On', + options: [ + { value: 'desk', label: 'Desk' }, + { value: 'laptop', label: 'Laptop', disabled: true } + ], + selected: 'desk', + onSelect: vi.fn(), + onClose: vi.fn() + }) + ) + }) + + const rows = renderer!.root.findAllByType('Pressable') + expect(rows.map((row) => row.props.accessible)).toEqual([true, true]) + expect(rows.map((row) => row.props.accessibilityRole)).toEqual(['button', 'button']) + expect(rows.map((row) => row.props.accessibilityState)).toEqual([ + { disabled: false, selected: true }, + { disabled: true, selected: false } + ]) + }) +}) diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx index 25da404632f..9456307091f 100644 --- a/mobile/src/components/PickerModal.tsx +++ b/mobile/src/components/PickerModal.tsx @@ -20,6 +20,7 @@ type Props = { onSelect: (value: T) => void onLongSelect?: (value: T) => void onClose: () => void + onAfterClose?: () => void zIndex?: number } @@ -36,10 +37,11 @@ export function PickerModal({ onSelect, onLongSelect, onClose, + onAfterClose, zIndex }: Props) { return ( - + {title} @@ -72,6 +74,9 @@ function PickerModalContent({ {i > 0 && } [ styles.row, diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts index bdd9e9225cb..a11690047b0 100644 --- a/mobile/src/files/file-tree.test.ts +++ b/mobile/src/files/file-tree.test.ts @@ -34,6 +34,18 @@ describe('file-tree', () => { ]) }) + it('orders numbered names naturally, matching the desktop explorer', () => { + const cache: DirectoryCache = { + '': { entries: [entry('100 - b.txt'), entry('9 - c.txt'), entry('99 - a.txt')] } + } + + expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([ + 'file:9 - c.txt', + 'file:99 - a.txt', + 'file:100 - b.txt' + ]) + }) + it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => { const cache: DirectoryCache = { '': { diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index a9283cfa457..71f26ca155b 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -1,5 +1,6 @@ // Pure tree projection for the mobile file explorer. Mobile mirrors desktop // browse semantics by flattening cached files.readDir results as folders open. +import { compareFileNames } from '../../../src/shared/file-name-sort' export type MobileDirEntry = { name: string @@ -118,7 +119,7 @@ function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 } - return a.name.localeCompare(b.name) + return compareFileNames(a.name, b.name) } export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean { diff --git a/mobile/src/host-route-action-state.test.ts b/mobile/src/host-route-action-state.test.ts index c662d2d3418..7f0ff6375a1 100644 --- a/mobile/src/host-route-action-state.test.ts +++ b/mobile/src/host-route-action-state.test.ts @@ -2,11 +2,23 @@ import { describe, expect, it } from 'vitest' import { createInitialHostRouteActionState, + hostNewWorktreeRoute, + hostNewWorktreeSessionRoute, resolveHostRouteActionState, setHostRouteNewWorktreeVisible } from './host-route-action-state' describe('host route action state', () => { + it('encodes opaque host ids in the new-worktree route segment', () => { + expect(hostNewWorktreeRoute('relay/one#50%')).toBe('/h/relay%2Fone%2350%25?action=newWorktree') + }) + + it('preserves opaque host and worktree ids after creation', () => { + expect(hostNewWorktreeSessionRoute('relay/one#50%', 'repo/one#20%', 'Relay workspace')).toBe( + '/h/relay%2Fone%2350%25/session/repo%2Fone%2320%25?name=Relay+workspace&created=1' + ) + }) + it('opens new worktree modal on an initial newWorktree action', () => { expect(createInitialHostRouteActionState('newWorktree')).toEqual({ routeAction: 'newWorktree', diff --git a/mobile/src/host-route-action-state.ts b/mobile/src/host-route-action-state.ts index bdcb7a5249c..a89c03fb472 100644 --- a/mobile/src/host-route-action-state.ts +++ b/mobile/src/host-route-action-state.ts @@ -3,6 +3,19 @@ export type HostRouteActionState = { showNewWorktree: boolean } +export function hostNewWorktreeRoute(hostId: string): `/h/${string}?action=newWorktree` { + return `/h/${encodeURIComponent(hostId)}?action=newWorktree` +} + +export function hostNewWorktreeSessionRoute( + hostId: string, + worktreeId: string, + worktreeName: string +): `/h/${string}/session/${string}?${string}` { + const params = new URLSearchParams({ name: worktreeName, created: '1' }) + return `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}?${params}` +} + export function createInitialHostRouteActionState( routeAction: string | undefined ): HostRouteActionState { diff --git a/mobile/src/navigation/host-stack-navigation.test.ts b/mobile/src/navigation/host-stack-navigation.test.ts index dc3a94cfe7a..43848f35449 100644 --- a/mobile/src/navigation/host-stack-navigation.test.ts +++ b/mobile/src/navigation/host-stack-navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { coordinateHostStackNavigation, hostStackHostRoute, + hostStackRouteHref, navigateToHostStackRoute, type HostStackNavigationState } from './host-stack-navigation' @@ -69,7 +70,7 @@ describe('host stack navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', TARGET) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -89,7 +90,12 @@ describe('host stack navigation', () => { rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] }) ) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(rootLayoutScopedState(committedHostState('host/one'))) expect(harness.navigation.dispatch).toHaveBeenCalledWith({ @@ -103,7 +109,12 @@ describe('host stack navigation', () => { it('survives the state emitted before the root navigator has hydrated', () => { const harness = navigationHarness(undefined) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) expect(() => harness.setState(undefined)).not.toThrow() expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -112,10 +123,30 @@ describe('host stack navigation', () => { expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1) }) + it('replaces through Expo Router when root state omits the mounted child stack', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() + + navigateToHostStackRoute(harness.navigation, { push: vi.fn(), replace }, 'host/one', TARGET) + harness.setState({ + index: 0, + routes: [{ name: 'h', params: { hostId: 'host/one' } }] + }) + + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).toHaveBeenCalledWith(hostStackRouteHref(TARGET)) + expect(harness.listenerCount()).toBe(0) + }) + it('abandons the transition when navigation leaves the host route it was waiting on', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState({ index: 0, routes: [{ name: 'h' }] }) harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState(committedHostState('host/one')) @@ -127,7 +158,12 @@ describe('host stack navigation', () => { it('ignores a different host whose id merely decodes badly', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(committedHostState('100%')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -138,7 +174,7 @@ describe('host stack navigation', () => { const controller = navigateToHostStackRoute( harness.navigation, - { push: vi.fn() }, + { push: vi.fn(), replace: vi.fn() }, 'host/one', TARGET ) @@ -156,7 +192,7 @@ describe('host stack navigation', () => { it('retargets a still-pending transition to the same host instead of pushing twice', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - const router = { push } + const router = { push, replace: vi.fn() } const pending = coordinateHostStackNavigation( null, diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts index 6f73dd3e57a..cb3b87cd98f 100644 --- a/mobile/src/navigation/host-stack-navigation.ts +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -35,8 +35,14 @@ export type HostStackRootNavigation = { export type HostStackHostRoute = `/h/${string}` +export type HostStackRouteHref = Readonly<{ + pathname: `/h/${string}` + params: Readonly> +}> + export type HostStackRouter = { push: (route: HostStackHostRoute) => void + replace: (route: HostStackRouteHref) => void } export type HostStackNavigationController = Readonly<{ @@ -54,6 +60,10 @@ export function hostStackHostRoute(hostId: string): HostStackHostRoute { return `/h/${encodeURIComponent(hostId)}` } +export function hostStackRouteHref(target: HostStackRouteTarget): HostStackRouteHref { + return { pathname: `/h/${target.name}`, params: target.params } +} + // Why: the host is pushed as an encoded segment, so the committed route may hold // either form — an id with `/`, `#`, or `%` must still match its own push. function hostParamMatches(param: unknown, expectedHostId: string): boolean { @@ -144,6 +154,10 @@ export function navigateToHostStackRoute( } const hostStack = hostContainer && mountedHostStack(hostContainer, hostId) if (!hostStack) { + if (hostContainer && hostParamMatches(hostContainer.params?.hostId, hostId)) { + dispose() + router.replace(hostStackRouteHref(selectedTarget)) + } return } dispose() diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index e41df45a8a2..eab13fdc046 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -47,7 +47,12 @@ describe('notification route coordination', () => { const harness = navigationHarness(undefined) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, target!.hostId, target!.sessionTarget!) + navigateToHostStackRoute( + harness.navigation, + { push, replace: vi.fn() }, + target!.hostId, + target!.sessionTarget! + ) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index 6b5762aefb4..779aa2425e5 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildLocalNotificationData, getNotificationNavigationTarget } from './notification-routing' +import { + buildLocalNotificationData, + getNotificationNavigationTarget, + notificationCredentialRecoveryRoute +} from './notification-routing' describe('notification routing', () => { it('includes the host id in locally scheduled notification data', () => { @@ -55,4 +59,32 @@ describe('notification routing', () => { ) ).toBeNull() }) + + it.each([ + ['missing', 're-pair'], + ['temporarily-unavailable', 'retry'] + ] as const)('routes %s host credentials to %s recovery', (status, recovery) => { + const target = getNotificationNavigationTarget( + { hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' }, + { + knownHostIds: new Set(['host-1']), + credentialStatusByHostId: new Map([['host-1', status]]) + } + ) + + expect(target).toMatchObject({ hostId: 'host-1', credentialRecovery: recovery }) + expect(notificationCredentialRecoveryRoute(target!)).toBe( + status === 'missing' ? '/pair-scan' : '/' + ) + }) + + it('keeps ready hosts on the requested notification destination', () => { + const target = getNotificationNavigationTarget( + { hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' }, + { credentialStatusByHostId: new Map([['host-1', 'ready']]) } + ) + + expect(target?.sessionTarget).not.toBeNull() + expect(notificationCredentialRecoveryRoute(target!)).toBeNull() + }) }) diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index feda52c596b..5f81fb3567d 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -1,5 +1,6 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import { mobileSessionRouteTarget } from '../session/mobile-session-route' +import type { HostCredentialStatus } from '../transport/types' export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test' @@ -18,6 +19,7 @@ export type LocalNotificationData = { export type NotificationNavigationOptions = { knownHostIds?: ReadonlySet + credentialStatusByHostId?: ReadonlyMap } function readNonEmptyString(value: unknown): string | null { @@ -46,8 +48,18 @@ export function buildLocalNotificationData( export type NotificationNavigationTarget = Readonly<{ hostId: string sessionTarget: HostStackRouteTarget | null + credentialRecovery?: 'retry' | 're-pair' }> +export function notificationCredentialRecoveryRoute( + target: NotificationNavigationTarget +): '/' | '/pair-scan' | null { + if (target.credentialRecovery === 're-pair') { + return '/pair-scan' + } + return target.credentialRecovery === 'retry' ? '/' : null +} + export function getNotificationNavigationTarget( data: unknown, options: NotificationNavigationOptions = {} @@ -66,8 +78,14 @@ export function getNotificationNavigationTarget( } const worktreeId = readNonEmptyString(record.worktreeId) + const credentialStatus = options.credentialStatusByHostId?.get(hostId) return { hostId, - sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null + sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null, + ...(credentialStatus === 'missing' + ? { credentialRecovery: 're-pair' as const } + : credentialStatus === 'temporarily-unavailable' + ? { credentialRecovery: 'retry' as const } + : {}) } } diff --git a/mobile/src/notifications/use-open-notification-route.ts b/mobile/src/notifications/use-open-notification-route.ts index 3a0809c7be8..31d4715c43e 100644 --- a/mobile/src/notifications/use-open-notification-route.ts +++ b/mobile/src/notifications/use-open-notification-route.ts @@ -2,7 +2,10 @@ import { useCallback } from 'react' import { useRouter } from 'expo-router' import { hostStackHostRoute } from '../navigation/host-stack-navigation' import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' -import type { NotificationNavigationTarget } from './notification-routing' +import { + notificationCredentialRecoveryRoute, + type NotificationNavigationTarget +} from './notification-routing' export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void { const openHostStackRoute = useOpenHostStackRoute() @@ -10,6 +13,11 @@ export function useOpenNotificationRoute(): (target: NotificationNavigationTarge return useCallback( (target) => { + const recoveryRoute = notificationCredentialRecoveryRoute(target) + if (recoveryRoute) { + router.push(recoveryRoute) + return + } if (target.sessionTarget) { openHostStackRoute(target.hostId, target.sessionTarget) return diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 86fcb0dc4a2..68258c22820 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -187,7 +187,7 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac disabled={!canAdvance} > - {isLast ? 'Send answer' : 'Next'} + {isLast ? 'Submit' : 'Next'} diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 2680064c94b..c8e62c146cb 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], + chatImagePreviewsByMessageId: {}, chatComposerText: '', setChatComposerText: vi.fn() } as unknown as MobileNativeChatController diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 4361c185d3c..699f8a1614b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -82,6 +82,7 @@ export function MobileNativeChatOverlay({ onLoadEarlier={session.loadEarlier} onSend={images.sendNativeChat} pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} composerText={controller.chatComposerText} onComposerTextChange={controller.setChatComposerText} onAttachImage={() => void images.attachImage('library')} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 05763d59707..21a5a7c4aa1 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({ vi.mock('./MobileNativeChatComposer', async () => { const React = await import('react') return { - MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => React.createElement('Composer', { + ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }) @@ -136,6 +141,10 @@ describe('MobileNativeChatView', () => { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } + function composer(): ReactTestInstance { + return renderer!.root.find((node) => node.type === 'Composer') + } + function bannerText(): string { const [alert, ...rest] = banners() expect(rest).toHaveLength(0) @@ -197,4 +206,42 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + + it('keeps a visible lock through a subscribed-end lease blip', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + expect(composer().props.disabled).toBe(true) + + await update({ inputLockReason: null }) + expect(composer().props.disabled).toBe(true) + await act(async () => vi.advanceTimersByTime(300)) + await update({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + + expect(composer().props.disabled).toBe(true) + expect(composer().props.placeholder).toBe('Waiting for terminal…') + } finally { + vi.useRealTimers() + } + }) + + it('unlocks after the lease stays ready', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + await update({ inputLockReason: null }) + await act(async () => vi.advanceTimersByTime(599)) + expect(composer().props.disabled).toBe(true) + + await act(async () => vi.advanceTimersByTime(1)) + + expect(composer().props.disabled).toBe(false) + expect(composer().props.placeholder).toBe('Message, @files, /commands') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a35f3caed3c..478d36267a7 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -33,6 +33,8 @@ import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' +const INPUT_LOCK_SETTLE_MS = 600 + /** Why the composer input is locked: the transport is disconnected, or the * terminal subscription has not acknowledged its input lease yet. */ export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' @@ -59,6 +61,9 @@ type Props = { /** Optimistic queued sends (owned by the route so they survive view switches). */ /** Optimistic user echoes, including any ridden-along image preview URIs. */ pending: MobileNativeChatPendingItem[] + /** Local photo URIs retained when the authoritative transcript replaces an + * optimistic image bubble. */ + imagePreviewsByMessageId?: Record /** Controlled composer text (owned by the route so dictation can write to it). */ composerText: string onComposerTextChange: (text: string) => void @@ -123,6 +128,7 @@ export function MobileNativeChatView({ onLoadEarlier, onSend, pending, + imagePreviewsByMessageId, composerText, onComposerTextChange, onAttachImage, @@ -175,8 +181,14 @@ export function MobileNativeChatView({ // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded, streaming, pending }), - [folded, streaming, pending] + () => + buildMobileNativeChatTransientData({ + folded, + streaming, + pending, + imagePreviewsByMessageId + }), + [folded, streaming, pending, imagePreviewsByMessageId] ) // Follow the tail as the conversation grows and keep the newest message above @@ -250,20 +262,18 @@ export function MobileNativeChatView({ const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 - // Composer-lock flicker guard: on a remote link, brief connState blips or lease - // hand-offs would otherwise toggle the lock placeholder on and off. Only surface - // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + // A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled. const rawLockReason = inputLockReason ?? null + const rawLockHeld = rawLockReason !== null const [lockHeld, setLockHeld] = useState(false) useEffect(() => { - if (rawLockReason === null) { - setLockHeld(false) + if (rawLockHeld === lockHeld) { return } - const timer = setTimeout(() => setLockHeld(true), 600) + const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS) return () => clearTimeout(timer) - }, [rawLockReason]) - const lockReason = lockHeld ? rawLockReason : null + }, [lockHeld, rawLockHeld]) + const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null return ( diff --git a/mobile/src/session/MobileTerminalLiveInputStatus.tsx b/mobile/src/session/MobileTerminalLiveInputStatus.tsx index dc135c3eb60..a27b09f07d2 100644 --- a/mobile/src/session/MobileTerminalLiveInputStatus.tsx +++ b/mobile/src/session/MobileTerminalLiveInputStatus.tsx @@ -10,11 +10,13 @@ type DictationStatus = { type MobileTerminalLiveInputStatusProps = { readonly dictation: DictationStatus readonly isAttaching: boolean + readonly liveInputText: string } export function MobileTerminalLiveInputStatus({ dictation, - isAttaching + isAttaching, + liveInputText }: MobileTerminalLiveInputStatusProps) { const title = dictation.isRecording ? 'Listening' @@ -31,14 +33,14 @@ export function MobileTerminalLiveInputStatus({ ? 'Preparing microphone' : isAttaching ? 'Uploading image to host' - : 'Tap to show keyboard' + : liveInputText || 'Tap to show keyboard' return ( {title} - + {detail} diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index fbdd9eb5dec..35e3cff3b60 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -12,13 +12,28 @@ vi.mock('expo-file-system', () => ({ File: vi.fn() })) -import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' +import { + ImageLibraryPermissionError, + pickMobileImage, + pickMobileImages, + type PickedMobileImage +} from './mobile-image-source-picker' const granted = { granted: true } as Awaited< ReturnType > const denied = { granted: false } as typeof granted +async function collectImages( + images: AsyncIterable +): Promise { + const collected: PickedMobileImage[] = [] + for await (const image of images) { + collected.push(image) + } + return collected +} + function fileFactory( bytes: Uint8Array, options?: { fileSize?: number; handleSize?: number | null; readError?: Error } @@ -68,6 +83,57 @@ describe('pickMobileImage', () => { expect(file.close).toHaveBeenCalledTimes(1) }) + it('returns every selected library photo in order', async () => { + const bytesByUri = new Map([ + ['file:///a.jpg', new Uint8Array([1])], + ['file:///b.jpg', new Uint8Array([2])], + ['file:///c.jpg', new Uint8Array([3])] + ]) + const createFile = vi.fn((uri: string) => { + const bytes = bytesByUri.get(uri)! + let read = false + return { + size: bytes.length, + open: () => ({ + size: bytes.length, + readBytes: () => { + if (read) { + return new Uint8Array() + } + read = true + return bytes + }, + close: vi.fn() + }) + } + }) + const launchLibrary = vi.fn().mockResolvedValue({ + canceled: false, + assets: [...bytesByUri].map(([uri, bytes]) => ({ uri, fileSize: bytes.length })) + }) + + const result = await collectImages( + pickMobileImages('library', { + requestLibraryPermission: vi.fn().mockResolvedValue(granted), + launchLibrary, + createFile + }) + ) + + expect(result.map((image) => image.uri)).toEqual([ + 'file:///a.jpg', + 'file:///b.jpg', + 'file:///c.jpg' + ]) + expect(launchLibrary).toHaveBeenCalledWith( + expect.objectContaining({ + allowsMultipleSelection: true, + orderedSelection: true, + selectionLimit: 0 + }) + ) + }) + it('throws when photo library permission is denied', async () => { await expect( pickMobileImage('library', { diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index bf9c529cc25..062085f7270 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -83,11 +83,12 @@ async function readUriAsBase64( } } -async function pickFromLibrary( +async function* pickFromLibrary( + multiple: boolean, requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. if (!permission.granted) { @@ -96,51 +97,84 @@ async function pickFromLibrary( const result = await launch({ mediaTypes: ['images'], base64: false, - allowsMultipleSelection: false, + allowsMultipleSelection: multiple, + ...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}), quality: 1 }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null - if (!base64) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.fileSize, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) } } -async function pickFromFiles( +async function* pickFromFiles( + multiple: boolean, launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const result = await launch({ type: 'image/*', - multiple: false, + multiple, copyToCacheDirectory: true }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - if (!asset?.uri) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) - return base64 ? { base64, uri: asset.uri } : null +} + +type MobileImagePickerDeps = { + readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync + readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync + readonly launchFiles?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory +} + +function pickMobileImagesWithMode( + source: MobileImageSource, + multiple: boolean, + deps?: MobileImagePickerDeps +): AsyncIterable { + if (source === 'library') { + return pickFromLibrary( + multiple, + deps?.requestLibraryPermission, + deps?.launchLibrary, + deps?.createFile + ) + } + return pickFromFiles(multiple, deps?.launchFiles, deps?.createFile) } export async function pickMobileImage( source: MobileImageSource, - deps?: { - readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync - readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync - readonly launchFiles?: typeof DocumentPicker.getDocumentAsync - readonly createFile?: MobileImageFileFactory - } + deps?: MobileImagePickerDeps ): Promise { - if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) + for await (const image of pickMobileImagesWithMode(source, false, deps)) { + return image } - return pickFromFiles(deps?.launchFiles, deps?.createFile) + return null +} + +export function pickMobileImages( + source: MobileImageSource, + deps?: MobileImagePickerDeps +): AsyncIterable { + return pickMobileImagesWithMode(source, true, deps) } diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts new file mode 100644 index 00000000000..a817b47b3ee --- /dev/null +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -0,0 +1,64 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { detectAgentPermission } from './mobile-native-chat-permission' +import type { parseAgentQuestion } from './mobile-native-chat-question' +import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' +import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' + +export type MobileNativeChatController = { + /** Whether a tab's effective view is chat (per-tab override, else the default). */ + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: MobileNativeChatPendingMessage[] + chatImagePreviewsByMessageId: Record + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + nativeChatStreamingText?: string + /** Agent mid-turn, regardless of whether chat is the visible view. */ + nativeChatStreamLive: boolean + /** Host/workspace/tab/session scope for stateful streaming suppression. */ + nativeChatStreamScopeKey: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + /** The pending ask, already null while dismissed (dismissal lives here so it + * survives the chat-view subtree unmounting on a view toggle). */ + nativeChatAsk: ReturnType + /** Stable key for the current ask card (keys the card component). */ + nativeChatAskKey: string | null + /** Hide the current ask until a genuinely different question arrives. */ + dismissNativeChatAsk: () => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatQuestionAnswer: (text: string) => Promise + handleNativeChatSend: (text: string, images?: string[]) => Promise + /** Outcome-preserving send: callers that pasted terminal input beforehand + * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a + * caller passes its own `deadline` so the paste it already spent and this text + * body share one budget instead of holding the composer for two. */ + handleNativeChatSendWithOutcome: ( + text: string, + images?: string[], + deadline?: number + ) => Promise + /** Launch-context text still parked on the agent's TUI input line, or null. + * Image sends read it to size their leading clear (one Ctrl+U per line). */ + readSeededLaunchDraft: () => string | null + /** Model/session-option pickers for the composer, or null when the active + * agent has no session-option catalog. */ + nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null +} diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts index 3171b818050..69277dd09ae 100644 --- a/mobile/src/session/mobile-native-chat-diff.test.ts +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -36,6 +36,18 @@ describe('diffFromText', () => { expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() }) + // Parity guard: the mobile module re-exports the shared parser, so the -- / ++ + // content collision must stay fixed on both platforms. + it('counts a removed -- comment line despite the --- prefix collision', () => { + const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new') + expect(lines).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '--sql comment' }, + { kind: 'add', text: 'new' } + ]) + }) + it('caps large diffs before creating render rows', () => { const text = Array.from( { length: 1_000 }, diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts new file mode 100644 index 00000000000..4c0cee804c1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, + type PendingImagePreviewEcho +} from './mobile-native-chat-draft-reconcile' + +function userText(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function pending(id: string, images: string[], expectedOccurrence = 1): PendingImagePreviewEcho { + return { id, text: '', images, expectedOccurrence, baselineTailMessageId: null } +} + +describe('mobile native chat image preview reconciliation', () => { + it('keeps separate adjacent image-only sends independently reconcilable', () => { + const landed = findLandedImagePreviewEchoes( + [ + userText('source-a', '[Image: source: /tmp/a.png]'), + userText('source-b', '[Image: source: /tmp/b.png]') + ], + [pending('pending-a', ['file:///a.jpg']), pending('pending-b', ['file:///b.jpg'], 2)] + ) + + expect(landed).toEqual([ + { pendingId: 'pending-a', messageId: 'source-a', images: ['file:///a.jpg'] }, + { pendingId: 'pending-b', messageId: 'source-b', images: ['file:///b.jpg'] } + ]) + }) + + it('waits for a complete multi-image turn as transcript source frames stream in', () => { + const entry = pending('pending', ['file:///a.jpg', 'file:///b.jpg']) + const sourceA = userText('source-a', '[Image: source: /tmp/a.png]') + const sourceB = userText('source-b', '[Image: source: /tmp/b.png]') + + expect(findLandedImagePreviewEchoes([sourceA], [entry])).toEqual([]) + expect(findLandedImagePreviewEchoes([sourceA, sourceB], [entry])).toEqual([]) + expect( + findLandedImagePreviewEchoes( + [sourceA, sourceB, userText('prompt', '[Image #1] [Image #2]')], + [entry] + ) + ).toEqual([ + { + pendingId: 'pending', + messageId: 'prompt', + images: ['file:///a.jpg', 'file:///b.jpg'] + } + ]) + }) + + it('moves an early standalone preview to the later folded prompt id', () => { + const sessionKey = 'host\0worktree\0tab\0session' + const previous = { [sessionKey]: { source: ['file:///a.jpg'] } } + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', '[Image #1]') + ] + + expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({ + [sessionKey]: { prompt: ['file:///a.jpg'] } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 1af010ff503..360fd22587b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,6 +1,7 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { isImageSourceUserTurn, + normalizeImageTranscriptMessages, stripImagePromptMarker } from './mobile-native-chat-image-transcript-markers' @@ -61,6 +62,149 @@ export function countImageSourceTurnsAfter( return count } +export type PendingImagePreviewEcho = { + id: string + text: string + images?: string[] + expectedOccurrence: number + baselineTailMessageId: string | null +} + +export type LandedImagePreviewEcho = { + pendingId: string + messageId: string + images: string[] +} + +const SENT_IMAGE_PREVIEW_LIMIT = 32 +const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8 + +export function mergeLandedImagePreviewEchoes( + previous: Record>, + sessionKey: string, + landed: readonly LandedImagePreviewEcho[] +): Record> { + const entries = Object.entries(previous[sessionKey] ?? {}) + for (const preview of landed) { + const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) + if (existingIndex >= 0) { + entries.splice(existingIndex, 1) + } + entries.push([preview.messageId, preview.images]) + } + const next = { ...previous } + delete next[sessionKey] + next[sessionKey] = Object.fromEntries(entries.slice(-SENT_IMAGE_PREVIEW_LIMIT)) + for (const key of Object.keys(next).slice(0, -SENT_IMAGE_PREVIEW_SESSION_LIMIT)) { + delete next[key] + } + return next +} + +function imagePreviewReplacementMessageId( + messages: readonly NativeChatMessage[], + sourceIndex: number +): string | null { + const source = messages[sourceIndex] + if (!source || !isImageSourceUserTurn(source)) { + return null + } + let nextIndex = sourceIndex + 1 + while ( + messages[nextIndex]?.source === source.source && + isImageSourceUserTurn(messages[nextIndex]!) + ) { + nextIndex++ + } + const prompt = messages[nextIndex] + const firstText = prompt?.blocks.find((block) => block.type === 'text') + return prompt?.role === 'user' && + prompt.source === source.source && + firstText?.type === 'text' && + stripImagePromptMarker(firstText.text) !== firstText.text + ? prompt.id + : null +} + +/** Moves previews forward when a progressive source-only transcript frame later + * folds into the marker-prefixed prompt with a different authoritative id. */ +export function migrateImagePreviewMessageIds( + previous: Record>, + sessionKey: string, + messages: readonly NativeChatMessage[] +): Record> { + const sessionPreviews = previous[sessionKey] + if (!sessionPreviews) { + return previous + } + const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) + let nextSession: Record | null = null + for (const [messageId, images] of Object.entries(sessionPreviews)) { + const sourceIndex = messageIndexById.get(messageId) + if (sourceIndex === undefined) { + continue + } + const replacementId = imagePreviewReplacementMessageId(messages, sourceIndex) + if (!replacementId) { + continue + } + nextSession ??= { ...sessionPreviews } + delete nextSession[messageId] + nextSession[replacementId] = [...(nextSession[replacementId] ?? []), ...images] + } + return nextSession ? { ...previous, [sessionKey]: nextSession } : previous +} + +/** Binds local preview URIs to the authoritative transcript turn that replaced + * the optimistic bubble. Host paths and marker-only Codex turns cannot render + * the phone-local photo without this handoff. */ +export function findLandedImagePreviewEchoes( + messages: readonly NativeChatMessage[], + entries: readonly PendingImagePreviewEcho[] +): LandedImagePreviewEcho[] { + const normalized = normalizeImageTranscriptMessages(messages) + const messageIndexById = new Map(normalized.map((message, index) => [message.id, index])) + const claimedMessageIds = new Set() + const landed: LandedImagePreviewEcho[] = [] + + for (const entry of entries) { + if (!entry.images?.length) { + continue + } + const targetText = entry.text.trim() + const candidates = normalized.filter((message) => { + if (message.role !== 'user') { + return false + } + if (targetText) { + return normalizedUserText(message) === targetText + } + const imageCount = message.blocks.filter(isImageRefBlock).length + return message.blocks.length === 0 || imageCount >= entry.images!.length + }) + const tailIndex = entry.baselineTailMessageId + ? messageIndexById.get(entry.baselineTailMessageId) + : -1 + const occurrenceIndex = Math.max(0, entry.expectedOccurrence - 1) + const candidate = targetText + ? candidates[occurrenceIndex] + : candidates.filter( + (message) => + tailIndex === undefined || (messageIndexById.get(message.id) ?? -1) > tailIndex + )[occurrenceIndex] + if ( + !candidate || + claimedMessageIds.has(candidate.id) || + (tailIndex !== undefined && (messageIndexById.get(candidate.id) ?? -1) <= tailIndex) + ) { + continue + } + claimedMessageIds.add(candidate.id) + landed.push({ pendingId: entry.id, messageId: candidate.id, images: entry.images }) + } + return landed +} + export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 387736f065e..7daa4babea6 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -97,6 +97,24 @@ describe('resolveMobileNativeChat', () => { ).toBeNull() }) + // Why: omp's hook reports no transcript path either, so mobile can only show + // its chat when the serving host is the one holding the session file. + it('admits omp only when its transcript is readable by the serving host', () => { + const tab = { type: 'terminal', launchAgent: 'omp' } + expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({ + agent: 'omp' + }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment')) + ).toMatchObject({ agent: 'omp' }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh')) + ).toBeNull() + expect(canShowMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh'))).toBe( + false + ) + }) + it('returns null for a plain shell (no agent)', () => { expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull() }) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 2f997c2cdc5..abda64b04ab 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -1,10 +1,14 @@ import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' -import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from '../../../src/shared/native-chat-agent-support' // Why: native chat renders an agent's own JSONL transcript, and the host -// resolver knows these transcript layouts. Grok is additionally gated on host -// readability because Model-A SSH stores its transcript on the remote target. +// resolver knows these transcript layouts. Agents whose hook reports no +// transcript path (Grok, omp) are additionally gated on host readability, +// because Model-A SSH stores their transcript on the remote target. export function isMobileNativeChatTranscriptReadable( connectionId: string | null | undefined ): boolean { @@ -50,7 +54,7 @@ export function resolveMobileNativeChat( if (!agent || !isNativeChatSupportedAgent(agent)) { return null } - if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) { + if (nativeChatRequiresLocalTranscript(agent) && !nativeChatTranscriptIsLocalReadable) { return null } return { diff --git a/mobile/src/session/mobile-native-chat-image-attachment.test.ts b/mobile/src/session/mobile-native-chat-image-attachment.test.ts index 57c3f67bd1c..e61116f3caa 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.test.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment' +import { uploadMobileNativeChatImages } from './mobile-native-chat-image-attachment' function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } @@ -16,6 +16,10 @@ function methodNotFound(id: string): RpcResponse { } } +function failed(id: string, message: string): RpcResponse { + return { id, ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } } +} + function clientWithResponses(responses: RpcResponse[]): Pick & { calls: { method: string; params: unknown }[] } { @@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick { +describe('uploadMobileNativeChatImages', () => { it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => { const client = clientWithResponses([ methodNotFound('start'), ok('save', '/tmp/orca-attach.png') ]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => 'conn-7', - pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'AAAA', uri: 'file:///photo.jpg' }]) }) - expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }) + expect(result).toEqual([{ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }]) // Native chat defers the paste to submit — nothing is sent to the terminal here. expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile') expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' }) }) + it('uploads all three selected images in picker order', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + ok('save-b', '/tmp/b.png'), + methodNotFound('start-c'), + ok('save-c', '/tmp/c.png') + ]) + + const order: string[] = [] + async function* pickImages() { + for (const image of [ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' }, + { base64: 'CCCC', uri: 'file:///c.jpg' } + ]) { + order.push(`read:${image.uri}`) + yield image + } + } + const result = await uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => 'conn-7', + pickImages, + onImageUploaded: (image) => order.push(`uploaded:${image.previewUri}`) + }) + + expect(result).toEqual([ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' }, + { path: '/tmp/b.png', previewUri: 'file:///b.jpg' }, + { path: '/tmp/c.png', previewUri: 'file:///c.jpg' } + ]) + expect(order).toEqual([ + 'read:file:///a.jpg', + 'uploaded:file:///a.jpg', + 'read:file:///b.jpg', + 'uploaded:file:///b.jpg', + 'read:file:///c.jpg', + 'uploaded:file:///c.jpg' + ]) + }) + it('returns null when the picker is cancelled and uploads nothing', async () => { const client = clientWithResponses([]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null) + pickImages: vi.fn().mockResolvedValue([]) }) - expect(result).toBeNull() + expect(result).toEqual([]) expect(client.calls).toEqual([]) }) + it('reports completed uploads before a later image fails', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + failed('save-b', 'upload failed') + ]) + const onImageUploaded = vi.fn() + + await expect( + uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => null, + pickImages: vi.fn().mockResolvedValue([ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' } + ]), + onImageUploaded + }) + ).rejects.toThrow('upload failed') + expect(onImageUploaded).toHaveBeenCalledOnce() + expect(onImageUploaded).toHaveBeenCalledWith({ + path: '/tmp/a.png', + previewUri: 'file:///a.jpg' + }) + }) + it('falls back to an inline data uri for the preview when the picker omits a uri', async () => { const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')]) - const result = await uploadMobileNativeChatImage('files', { + const result = await uploadMobileNativeChatImages('files', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'BBBB' }]) }) - expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }) + expect(result).toEqual([{ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }]) }) it('signals upload start only after a real image is picked', async () => { const onUploadStart = vi.fn() const cancelledClient = clientWithResponses([]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client: cancelledClient, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null), + pickImages: vi.fn().mockResolvedValue([]), onUploadStart }) expect(onUploadStart).not.toHaveBeenCalled() const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }), + pickImages: vi.fn().mockResolvedValue([{ base64: 'CCCC', uri: 'file:///y.jpg' }]), onUploadStart }) expect(onUploadStart).toHaveBeenCalledTimes(1) diff --git a/mobile/src/session/mobile-native-chat-image-attachment.ts b/mobile/src/session/mobile-native-chat-image-attachment.ts index f81b9d2ad98..fc9a962bacf 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.ts @@ -13,34 +13,67 @@ export type PendingNativeChatImage = { readonly previewUri: string } -export type UploadNativeChatImageDeps = { +export function appendPendingNativeChatImages( + current: readonly PendingNativeChatImage[], + uploaded: readonly Omit[], + idCounter: { current: number } +): PendingNativeChatImage[] { + return [ + ...current, + ...uploaded.map((image) => { + idCounter.current += 1 + return { id: `img-${idCounter.current}`, ...image } + }) + ] +} + +export type UploadNativeChatImagesDeps = { readonly client: Pick readonly getConnectionId: () => Promise // Injected so this module stays free of expo/react-native imports (unit-testable). - readonly pickImage: (source: MobileImageSource) => Promise + readonly pickImages: ( + source: MobileImageSource + ) => + | Iterable + | AsyncIterable + | Promise | AsyncIterable> // Fired once the user has picked an image and the host upload is about to start — // lets the UI show the attach spinner only for the transfer, not the picker. readonly onUploadStart?: () => void + /** Retains each completed upload if a later image in the same selection fails. */ + readonly onImageUploaded?: (image: Omit) => void } /** Picks an image and uploads it to the host, returning the host path + a local * preview URI — but does NOT paste it into the terminal. Unlike the terminal * attach flow, native chat holds the image as a composer chip and rides it along * on submit (desktop parity), so the chip and the agent input never diverge. - * Returns null when the user cancels the picker. */ -export async function uploadMobileNativeChatImage( + * Returns an empty array when the user cancels the picker. */ +export async function uploadMobileNativeChatImages( source: MobileImageSource, - { client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps -): Promise | null> { - const picked = await pickImage(source) - if (!picked) { - return null + { + client, + getConnectionId, + pickImages, + onUploadStart, + onImageUploaded + }: UploadNativeChatImagesDeps +): Promise[]> { + const picked = await pickImages(source) + const uploaded: Omit[] = [] + let connectionId: string | null = null + for await (const image of picked) { + if (uploaded.length === 0) { + onUploadStart?.() + connectionId = await getConnectionId() + } + const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId }) + // Prefer the picker's local URI for the thumbnail; fall back to an inline data + // URI when the source omitted one (RN renders both). + const previewUri = image.uri ?? `data:image/png;base64,${image.base64}` + const result = { path, previewUri } + uploaded.push(result) + onImageUploaded?.(result) } - onUploadStart?.() - const connectionId = await getConnectionId() - const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId }) - // Prefer the picker's local URI for the thumbnail; fall back to an inline data - // URI when the source omitted one (RN renders both). - const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}` - return { path, previewUri } + return uploaded } diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 46ac4730f2e..cf1c59adf0f 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick { it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => { - const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const client = clientWithResponses([ + sendResult(true), + sendResult(true), + sendResult(true), + sendResult(true) + ]) const ok = await pasteMobileNativeChatImagePaths({ client, terminal: 'term-1', deviceToken: 'device-9', - imagePaths: ['/tmp/a.png', '/tmp/b.png'] + imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'] }) expect(ok).toBe(true) - expect(client.calls).toHaveLength(3) + expect(client.calls).toHaveLength(4) // Leading Ctrl+U clears any stale input so a retry can't duplicate the image. expect(client.calls[0]).toEqual({ method: 'terminal.send', @@ -50,6 +55,7 @@ describe('pasteMobileNativeChatImagePaths', () => { }) expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~') expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~') + expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~') }) it('stops and reports failure as soon as a paste is rejected', async () => { diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts new file mode 100644 index 00000000000..b18136a9d47 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -0,0 +1,91 @@ +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number + /** Local preview URIs carried by the send for its optimistic echo. */ + images?: string[] + baselineTailMessageId: string | null +} + +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number + baselineTailMessageId: string | null +} + +type PendingByKey = Record + +export function combineMobileNativeChatPending( + session: MobileNativeChatPendingMessage[], + waiting: readonly MobileNativeChatPendingMessage[] +): MobileNativeChatPendingMessage[] { + if (waiting.length === 0) { + return session + } + const sessionIds = new Set(session.map((item) => item.id)) + return [...session, ...waiting.filter((item) => !sessionIds.has(item.id))] +} + +export function appendMobileNativeChatPending( + previous: PendingByKey, + key: string, + id: string, + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[] +): PendingByKey { + const current = previous[key] ?? [] + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const expectedImageEchoOrdinal = + current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1 + return { + ...previous, + [key]: [ + ...current, + { + id, + text, + expectedOccurrence: + origin.normalizedText === '' + ? expectedImageEchoOrdinal + : origin.baselineOccurrences + earlierOutstanding + 1, + baselineTailMessageId: origin.baselineTailMessageId, + ...(images?.length ? { images } : {}) + } + ] + } +} + +export function mergeWaitingSessionPending( + previous: PendingByKey, + sessionKey: string, + waiting: readonly MobileNativeChatPendingMessage[] +): PendingByKey { + const current = previous[sessionKey] ?? [] + const currentIds = new Set(current.map((item) => item.id)) + const moved = waiting.filter((item) => !currentIds.has(item.id)) + return moved.length > 0 ? { ...previous, [sessionKey]: [...current, ...moved] } : previous +} + +export function removeWaitingSessionPending( + previous: PendingByKey, + draftKey: string, + movedIds: ReadonlySet +): PendingByKey { + const remaining = (previous[draftKey] ?? []).filter((item) => !movedIds.has(item.id)) + if (remaining.length > 0) { + return { ...previous, [draftKey]: remaining } + } + if (!(draftKey in previous)) { + return previous + } + const next = { ...previous } + delete next[draftKey] + return next +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index b4a273d31ca..c67257ec24d 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -116,6 +116,36 @@ describe('buildMobileNativeChatTransientData', () => { expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) }) + it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => { + const folded = foldMobileNativeChatMessages([ + user('source', '[Image: source: /tmp/a.png]'), + user('prompt', '[Image #1] look at this') + ]) + const result = buildMobileNativeChatTransientData({ + folded, + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data).toHaveLength(1) + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' }, + { type: 'text', text: 'look at this' } + ]) + }) + + it('restores the local preview onto a marker-only transcript turn', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) + }) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { // Whether text streams at all is the gate's call // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index e11b9ac56f4..b3d4ba1c0fd 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,7 +3,7 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' import { stripNoiseMessages } from './mobile-native-chat-noise' @@ -55,15 +55,37 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat export function buildMobileNativeChatTransientData({ folded, streaming, - pending + pending, + imagePreviewsByMessageId }: { folded: NativeChatMessage[] /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ streaming: string | null pending: MobileNativeChatPendingItem[] + imagePreviewsByMessageId?: Record }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const renderedFolded = folded.map((message) => { + const previews = imagePreviewsByMessageId?.[message.id] + if (message.role !== 'user' || !previews?.length) { + return message + } + let previewIndex = 0 + const blocks = message.blocks.map((block) => { + if (!isImageRefBlock(block)) { + return block + } + const url = previews[previewIndex] + previewIndex += 1 + return url ? { ...block, url } : block + }) + while (previewIndex < previews.length) { + blocks.push({ type: 'image-ref', url: previews[previewIndex] }) + previewIndex += 1 + } + return { ...message, blocks } + }) const data: NativeChatMessage[] = [ - ...folded, + ...renderedFolded, ...(streaming ? [ { @@ -88,5 +110,5 @@ export function buildMobileNativeChatTransientData({ source: 'transcript' as const })) ] - return { folded, streaming, data } + return { folded: renderedFolded, streaming, data } } diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 56b9fac7aa4..d9126475bc3 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -81,7 +81,7 @@ describe('mobile session route', () => { name: 'Fix #1' }) - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a1ed942316f..a9cd1201639 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ composerText: '', setComposerText: vi.fn(), pending: [], + imagePreviewsByMessageId: {}, captureSendOrigin, readSeededLaunchDraft: () => null, readSeededLaunchDraftSeed: () => null, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 49d75409c05..46012dadbe4 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,99 +1,29 @@ -import { - useCallback, - useLayoutEffect, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction -} from 'react' +import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { - parseAskFromStatus, - type AskAnswerSelection, - type AskPrompt -} from './mobile-native-chat-ask' import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' -import { detectAgentPermission } from './mobile-native-chat-permission' -import { parseAgentQuestion } from './mobile-native-chat-question' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' -import { - useMobileNativeChatDrafts, - type MobileNativeChatPendingMessage -} from './use-mobile-native-chat-drafts' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options' -import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' +import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' + +export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 -export type MobileNativeChatController = { - /** Whether a tab's effective view is chat (per-tab override, else the default). */ - isTabChatView: (tabId: string) => boolean - toggleTabChatView: (tabId: string) => void - showNativeChat: boolean - showNativeChatRef: MutableRefObject - /** Resolved agent for the active chat tab (names the empty-state copy). */ - nativeChatAgent: string | null - chatComposerText: string - setChatComposerText: Dispatch> - chatPending: MobileNativeChatPendingMessage[] - nativeChatSession: ReturnType - nativeChatAgentWorking: boolean - nativeChatStreamingText?: string - /** Agent mid-turn, regardless of whether chat is the visible view. */ - nativeChatStreamLive: boolean - /** Host/workspace/tab/session scope for stateful streaming suppression. */ - nativeChatStreamScopeKey: string - nativeChatPermission: ReturnType - nativeChatQuestion: ReturnType - /** The pending ask, already null while dismissed (dismissal lives here so it - * survives the chat-view subtree unmounting on a view toggle). */ - nativeChatAsk: ReturnType - /** Stable key for the current ask card (keys the card component). */ - nativeChatAskKey: string | null - /** Hide the current ask until a genuinely different question arrives. */ - dismissNativeChatAsk: () => void - handleNativeChatAnswerAsk: ( - prompt: AskPrompt, - selections: AskAnswerSelection[] - ) => Promise - handleNativeChatCancelAsk: () => Promise - handleNativeChatRespondPermission: (text: string) => Promise - handleNativeChatStop: () => void - nativeChatFilePaths: string[] - loadNativeChatFiles: (query: string) => void - handleNativeChatQuestionAnswer: (text: string) => Promise - handleNativeChatSend: (text: string, images?: string[]) => Promise - /** Outcome-preserving send: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a - * caller passes its own `deadline` so the paste it already spent and this text - * body share one budget instead of holding the composer for two. */ - handleNativeChatSendWithOutcome: ( - text: string, - images?: string[], - deadline?: number - ) => Promise - /** Launch-context text still parked on the agent's TUI input line, or null. - * Image sends read it to size their leading clear (one Ctrl+U per line). */ - readSeededLaunchDraft: () => string | null - /** Model/session-option pickers for the composer, or null when the active - * agent has no session-option catalog. */ - nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null -} - /** Owns mobile native-chat state and teardown outside the already dense session * route. The route remains responsible only for choosing and rendering the view. */ export function useMobileNativeChatController(args: { @@ -152,6 +82,7 @@ export function useMobileNativeChatController(args: { const nativeChatSession = useMobileNativeChatSession({ client, + sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), agent: activeChatResolution?.agent ?? null, sessionId: activeChatSessionId, transcriptPath: activeChatResolution?.transcriptPath ?? null @@ -160,6 +91,7 @@ export function useMobileNativeChatController(args: { composerText: chatComposerText, setComposerText: setChatComposerText, pending: chatPending, + imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, @@ -201,7 +133,8 @@ export function useMobileNativeChatController(args: { } = useMobileNativeChatPrompts({ enabled: activeChatResolution != null, status: nativeChatStatus, - messages: nativeChatSession.messages + messages: nativeChatSession.messages, + transcriptLoading: nativeChatSession.transcriptLoading }) // A never-read transcript cannot prove that a dismissed prompt cleared. const nativeChatTranscriptSettled = @@ -326,6 +259,7 @@ export function useMobileNativeChatController(args: { chatComposerText, setChatComposerText, chatPending, + chatImagePreviewsByMessageId, nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index bf3f94bd239..154ef3fb870 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -269,6 +269,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it("keeps an image-only echo when an unrelated text send's echo lands", async () => { @@ -345,6 +346,29 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] }) + }) + + it('hands a marker-only image preview to the authoritative user bubble', async () => { + await mount('a') + const origin = state?.captureSendOrigin('') + act(() => { + if (origin) { + state?.acceptSend(origin, '', ['file:///a.jpg']) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('u1', '[Image #1]')] + }) + ) + ) + + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it('does not reconcile a repeated send against an older identical turn', async () => { @@ -722,21 +746,59 @@ describe('useMobileNativeChatDrafts', () => { } }) - it('accepts and clears the first send before a provider session id exists', async () => { + it('preserves first-send images through session assignment and transcript replacement', async () => { await mount('a') await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) - act(() => state?.setComposerText('start the session')) + const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + act(() => state?.setComposerText('look')) - const origin = state?.captureSendOrigin('start the session') + const origin = state?.captureSendOrigin('look') expect(origin).toMatchObject({ pendingKey: null }) act(() => { if (origin) { - state?.clearDraftForSend(origin, 'start the session') - state?.acceptSend(origin, 'start the session') + state?.clearDraftForSend(origin, 'look') + state?.acceptSend(origin, 'look', images) } }) expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' })) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]') + ] + }) + ) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]'), + userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look') + ] + }) + ) + ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images }) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a43bc8b286f..9da4dea6a9b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -3,35 +3,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, countUserTextOccurrences, + findLandedImagePreviewEchoes, findLandedUnconfirmedSends, + mergeLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, normalizedUserText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' +import { + appendMobileNativeChatPending, + combineMobileNativeChatPending, + mergeWaitingSessionPending, + removeWaitingSessionPending, + type MobileNativeChatPendingMessage, + type MobileNativeChatSendOrigin +} from './mobile-native-chat-pending-echo' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' -export type MobileNativeChatPendingMessage = { - id: string - text: string - expectedOccurrence: number - /** Local preview URIs of images ridden along on the send, rendered as thumbnails - * on the echo bubble so the sent photo shows before the transcript catches up. */ - images?: string[] - /** Transcript tail when sent — an image-only echo (no text to match) reconciles - * against new `[Image: source: …]` echo turns after this id, so pagination, - * agent replies, and unrelated text echoes can't clear it early. */ - baselineTailMessageId: string | null -} -export type MobileNativeChatSendOrigin = { - draftKey: string - pendingKey: string | null - normalizedText: string - baselineOccurrences: number - baselineTailMessageId: string | null -} +export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] +const NO_IMAGE_PREVIEWS: Record = {} // How long an ack-lost send waits for its transcript echo before the UI surfaces // that delivery remains unconfirmed. @@ -57,6 +51,9 @@ export function useMobileNativeChatDrafts(args: { composerText: string setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] + /** Phone-local previews rebound to the transcript message that replaced the + * optimistic echo, keyed by authoritative message id. */ + imagePreviewsByMessageId: Record captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null /** Launch-context text still believed to be parked on the agent's TUI input * line, or null once it has been declined or retired. Send paths size their @@ -91,6 +88,12 @@ export function useMobileNativeChatDrafts(args: { const [pendingBySession, setPendingBySession] = useState< Record >({}) + const [pendingWaitingForSession, setPendingWaitingForSession] = useState< + Record + >({}) + const [imagePreviewsBySession, setImagePreviewsBySession] = useState< + Record> + >({}) const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages @@ -162,41 +165,21 @@ export function useMobileNativeChatDrafts(args: { const acceptSend = useCallback( (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - // Why: the first prompt can be sent before the provider reports a session - // id; wait for an id before keying an optimistic echo. - if (!origin.pendingKey) { + if (!origin.pendingKey && !images?.length) { return } - const pendingKey = origin.pendingKey pendingCounterRef.current += 1 - setPendingBySession((previous) => { - const current = previous[pendingKey] ?? NO_PENDING_MESSAGES - const earlierOutstanding = current.filter( - (pending) => - pending.text.trim() === origin.normalizedText && - pending.expectedOccurrence > origin.baselineOccurrences - ).length - // An empty-text send reconciles by image-echo ordinal: every outstanding - // send's ridden-along images echo as `[Image: source: …]` turns after - // this send's baseline tail, ahead of this send's own echo. - const expectedImageEchoOrdinal = - current.reduce( - (sum, pending) => - sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)), - 0 - ) + 1 - const pending: MobileNativeChatPendingMessage = { - id: `pending-${pendingCounterRef.current}`, - text, - expectedOccurrence: - origin.normalizedText === '' - ? expectedImageEchoOrdinal - : origin.baselineOccurrences + earlierOutstanding + 1, - baselineTailMessageId: origin.baselineTailMessageId, - ...(images && images.length > 0 ? { images } : {}) - } - return { ...previous, [pendingKey]: [...current, pending] } - }) + const id = `pending-${pendingCounterRef.current}` + const key = origin.pendingKey + if (key) { + setPendingBySession((previous) => + appendMobileNativeChatPending(previous, key, id, origin, text, images) + ) + } else { + setPendingWaitingForSession((previous) => + appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + ) + } }, [] ) @@ -255,9 +238,7 @@ export function useMobileNativeChatDrafts(args: { const landedSet = new Set(landed) unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) for (const entry of landed) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } }, [messages, draftKey, pendingKey]) @@ -266,21 +247,49 @@ export function useMobileNativeChatDrafts(args: { return () => { mountedRef.current = false for (const entry of unconfirmedRef.current) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } unconfirmedRef.current = [] } }, []) - const pending = pendingKey - ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + const waitingForSession = draftKey + ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES useEffect(() => { - if (!pendingKey || pending.length === 0) { + if (!draftKey || !pendingKey || waitingForSession.length === 0) { return } + const movedIds = new Set(waitingForSession.map((item) => item.id)) + setPendingBySession((previous) => + mergeWaitingSessionPending(previous, pendingKey, waitingForSession) + ) + setPendingWaitingForSession((previous) => + removeWaitingSessionPending(previous, draftKey, movedIds) + ) + }, [draftKey, pendingKey, waitingForSession]) + + const sessionPending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + const pending = combineMobileNativeChatPending(sessionPending, waitingForSession) + useEffect(() => { + if (!pendingKey) { + return + } + setImagePreviewsBySession((previous) => + migrateImagePreviewMessageIds(previous, pendingKey, messages) + ) + if (pending.length === 0) { + return + } + const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending) + const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) + if (landedImagePreviews.length > 0) { + setImagePreviewsBySession((previous) => + mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews) + ) + } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] const landedCounts = new Map() @@ -297,12 +306,20 @@ export function useMobileNativeChatDrafts(args: { // baseline tail — text echoes are excluded so an unrelated outstanding // text send cannot clear it. Ordinal-vs-count stays stable when the effect // re-runs on the shrunken list, and ignores paginated-in history. - const next = current.filter((item) => - item.text.trim() === '' + const next = current.filter((item) => { + if (landedImagePendingIds.has(item.id)) { + return false + } + // Image echoes hand their local URIs to the authoritative message above; + // never drop them through the text-only fallback before that handoff. + if (item.images?.length) { + return true + } + return item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < - item.expectedOccurrence + item.expectedOccurrence : (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence - ) + }) if (next.length === current.length) { return previous } @@ -319,6 +336,9 @@ export function useMobileNativeChatDrafts(args: { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, pending, + imagePreviewsByMessageId: pendingKey + ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) + : NO_IMAGE_PREVIEWS, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 13cb5d96c48..a57e1c7520c 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -11,13 +11,13 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im // Fully stub the picker so the real expo/react-native chain never loads under // the vitest transform (react-native ships Flow syntax rolldown can't parse). vi.mock('./mobile-image-source-picker', () => ({ - pickMobileImage: vi.fn(), + pickMobileImages: vi.fn(), ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {} })) -import { pickMobileImage } from './mobile-image-source-picker' +import { pickMobileImages } from './mobile-image-source-picker' -const pick = vi.mocked(pickMobileImage) +const pick = vi.mocked(pickMobileImages) function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'r' } } @@ -124,7 +124,7 @@ describe('useMobileNativeChatImageAttachments', () => { } it('adds an uploaded image as a chip without pasting to the terminal', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) mount( baseArgs({ @@ -145,7 +145,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -208,7 +208,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => { // A single Ctrl+U kills only the last line, so the draft's earlier lines // would survive the clear and ride along with the image as prompt body. - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -242,7 +242,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -280,7 +280,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -321,7 +321,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and does not submit when the image paste is rejected', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -345,7 +345,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and reports failure when the paste transport throws', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') @@ -365,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const onSendError = vi.fn() @@ -387,7 +387,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('scopes chips to the tab that attached them', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const args = baseArgs({ client: client as unknown as RpcClient, baseSend }) @@ -426,7 +426,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let firstAttach: Promise | null = null await act(async () => { firstAttach = hook!.attachImage('library') @@ -438,7 +438,7 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.isAttaching).toBe(true) // A concurrent cancelled pick — its finally must leave the counter alone. - pick.mockResolvedValue(null) + pick.mockResolvedValue([]) await act(async () => { await hook!.attachImage('library') }) @@ -454,7 +454,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('clears only the chips that were sent, keeping one attached mid-send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), // first attach @@ -488,7 +488,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(releaseSettle).not.toBeNull() - pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' }) + pick.mockResolvedValue([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) await act(async () => { await hook!.attachImage('library') }) @@ -511,7 +511,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('aborts the send when the active terminal changes during the settle window', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -560,7 +560,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -596,7 +596,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -634,7 +634,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('still heals after the session screen unmounts and remounts (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -688,7 +688,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('retains the stale marker when a rejected healing clear blocks text-only send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -730,7 +730,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('does not reroute text when the active terminal changes during a healing clear', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let releaseClear: ((response: RpcResponse) => void) | null = null const deferredClear = new Promise((resolve) => { releaseClear = resolve @@ -774,7 +774,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('defers the heal instead of burning a rejected clear while the lease is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -818,8 +818,8 @@ describe('useMobileNativeChatImageAttachments', () => { it('heals rejected image submits independently across terminals', async () => { pick - .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) - .mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' }) + .mockResolvedValueOnce([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) + .mockResolvedValueOnce([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) const client = makeClient([ methodNotFound('start-a'), ok('save-a', '/tmp/a.png'), @@ -896,7 +896,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let attach: Promise | null = null await act(async () => { attach = hook!.attachImage('library') diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 38f9e462e46..dbcb97df527 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -5,11 +5,12 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { ImageLibraryPermissionError, - pickMobileImage, + pickMobileImages, type MobileImageSource } from './mobile-image-source-picker' import { - uploadMobileNativeChatImage, + appendPendingNativeChatImages, + uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -82,10 +83,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - const NO_ATTACHMENTS: PendingNativeChatImage[] = [] function withScopeAttachments( @@ -146,48 +143,53 @@ export function useMobileNativeChatImageAttachments({ // pick or pre-upload error never ran `onUploadStart`, so decrementing the // shared counter would clear a concurrent upload's in-flight flag early. let started = false + const uploadedImages: Omit[] = [] + let uploadError: unknown = null try { - const uploaded = await uploadMobileNativeChatImage(source, { + await uploadMobileNativeChatImages(source, { client, getConnectionId: getActiveWorktreeConnectionId, - pickImage: pickMobileImage, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), onUploadStart: () => { started = true attachingCount.current += 1 setIsAttaching(true) } }) - // Cancelled picker: no error, no toast. - if (!uploaded) { - return - } - idCounter.current += 1 - const chip = { id: `img-${idCounter.current}`, ...uploaded } - setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] })) - onAttachSuccess?.() } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) onError?.() if (connStateRef.current !== 'connected') { showToast('Attach failed (disconnected)', 1500) return } - if (error instanceof ImageLibraryPermissionError) { + if (uploadError instanceof ImageLibraryPermissionError) { showToast('Photo permission denied', 1500) return } - if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { showToast('Image too large to attach', 1500) return } showToast('Attach failed', 1500) - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current <= 0) { - attachingCount.current = 0 - setIsAttaching(false) - } - } } }, [ diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index f8a1d2a61e6..905b92155e6 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -15,14 +15,16 @@ const ASK = JSON.stringify({ function promptsFor( status: Partial | null, - messages: NativeChatMessage[] = [] + messages: NativeChatMessage[] = [], + transcriptLoading = false ): ReturnType { let captured: ReturnType | undefined function Probe(): null { captured = useMobileNativeChatPrompts({ enabled: true, status: status as AgentStatusEntry | null, - messages + messages, + transcriptLoading }) return null } @@ -118,6 +120,18 @@ describe('useMobileNativeChatPrompts ask state gate', () => { expect(promptsFor(null, askMessages).ask).not.toBeNull() }) + it('withholds retained transcript asks while the replacement read is unsettled', () => { + const prompts = promptsFor({ state: 'done' }, askMessages, true) + expect(prompts.ask).toBeNull() + expect(prompts.detectedAsk).toBeNull() + }) + + it('keeps a paused live status ask authoritative while the read is unsettled', () => { + const prompts = promptsFor({ state: 'waiting', interactivePrompt: ASK }, askMessages, true) + expect(prompts.ask).toMatchObject({ questions: [{ question: 'Which path?' }] }) + expect(prompts.detectedAsk).not.toBeNull() + }) + it('does not leak a paused-out sticky status prompt through the transcript fallback', () => { // The post-answer window: the status still carries the prompt while flipping // to `working`, and the transcript's tool-result row has not landed yet, so diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 717bd505aea..61b831dafd2 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask' import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' @@ -17,8 +17,11 @@ export function useMobileNativeChatPrompts(args: { enabled: boolean status: AgentStatusEntry | null | undefined messages: readonly NativeChatMessage[] + /** True while `messages` is an unsettled read (including the cached list held + * across a reconnect). Required: an ask derived from it may already be answered. */ + transcriptLoading: boolean }): MobileNativeChatPrompts { - const { enabled, status, messages } = args + const { enabled, status, messages, transcriptLoading } = args const blocked = status?.state === 'waiting' || status?.state === 'blocked' // Both permission paths sit inside the paused gate: an approval envelope can // outlive its answer (the host keeps it sticky), so only a waiting/blocked @@ -38,11 +41,16 @@ export function useMobileNativeChatPrompts(args: { () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), [status?.interactivePrompt, status?.toolName] ) - const askFromMessages = useMemo( - () => (askFromStatus ? null : extractPendingAsk(messages)), - [askFromStatus, messages] + const resolvedAsk = useMemo( + () => + resolveNativeChatAsk({ + liveAsk: askFromStatus, + messages, + transcriptSettled: !transcriptLoading + }), + [askFromStatus, transcriptLoading, messages] ) - + const askFromMessages = askFromStatus ? null : resolvedAsk const detectedAsk = askFromStatus ?? askFromMessages return { diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 844c311b827..8c685d00202 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -36,6 +36,7 @@ describe('useMobileNativeChatSession', () => { function Harness({ client }: { client: RpcClient | null }): null { state = useMobileNativeChatSession({ client, + sourceIdentity: 'host-a\0workspace-a', agent: 'claude', sessionId: 'session', transcriptPath: null @@ -456,14 +457,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { function Harness({ client, sessionId, - agent = 'claude' + agent = 'claude', + sourceIdentity = 'host-a\0workspace-a' }: { client: RpcClient | null sessionId: string | null agent?: string | null + sourceIdentity?: string }): null { const session = useMobileNativeChatSession({ client, + sourceIdentity, agent, sessionId, transcriptPath: null @@ -506,7 +510,8 @@ describe('useMobileNativeChatSession transcriptLoading', () => { it('re-reads instead of resurfacing a settled read when the same identity returns', async () => { // Leaving chat view nulls the agent, then returning restores the identity a // settled read already matched — but its list was cleared, so trusting it - // would report 'ready' over an empty transcript. + // would report 'ready' over an empty transcript. The last settled list for + // this identity keeps rendering while the re-read is in flight. const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) return () => {} @@ -524,12 +529,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' })) ) - expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + expect(renders[0]).toMatchObject({ + status: 'loading', + transcriptLoading: true, + ids: ['a-1'] + }) }) - it('re-reads instead of resurfacing a settled read after a reconnect', async () => { - // A reconnect swaps the client without moving the identity; the effect - // re-subscribes and clears the list, so the old outcome must not stand. + it('keeps the last settled list rendered while a swapped client re-reads', async () => { + // A manual-retry reconnect swaps the client without moving the identity; the + // old outcome must not stand ('loading', not 'ready'), but the cached + // transcript keeps rendering instead of collapsing to a full-screen spinner. const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) return () => {} @@ -538,13 +548,61 @@ describe('useMobileNativeChatSession transcriptLoading', () => { await mountAt(client, 'session-a') expect(renders.at(-1)).toMatchObject({ status: 'ready' }) - const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + let emitFresh: (frame: unknown) => void = () => {} + const reconnected = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + emitFresh = onData + return () => {} + }) + } as unknown as RpcClient renders.length = 0 await act(async () => renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' })) ) - expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + expect(renders[0]).toMatchObject({ + status: 'loading', + transcriptLoading: true, + ids: ['a-1'] + }) + // Every commit of the window, not just the first: the re-subscribe lands a + // commit after it, so clearing the cache there blanks the transcript the + // user actually sees while leaving a first-frame assertion green. + expect([...new Set(renders.map((entry) => entry.ids.join(',')))]).toEqual(['a-1']) + + // The fresh client's snapshot supersedes the held list. + await act(async () => + emitFresh({ type: 'snapshot', messages: [message('a-1'), message('a-2')], hasMore: false }) + ) + expect(renders.at(-1)).toMatchObject({ + status: 'ready', + transcriptLoading: false, + ids: ['a-1', 'a-2'] + }) + }) + + it('never holds a cached list across a host/workspace source change', async () => { + const firstClient = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + onData({ type: 'snapshot', messages: [message('source-a')], hasMore: false }) + return () => {} + }) + } as unknown as RpcClient + await mountAt(firstClient, 'session-a') + + const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + renders.length = 0 + await act(async () => + renderer?.update( + createElement(Harness, { + client: secondClient, + sessionId: 'session-a', + sourceIdentity: 'host-b\0workspace-b' + }) + ) + ) + + expect(renders[0]).toMatchObject({ status: 'loading', ids: [] }) }) it('never hands out the previous session’s messages under the new session id', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 56db7def584..6305d0418d9 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -1,4 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { + createNativeChatTranscriptRetention, + encodeNativeChatTranscriptIdentity +} from '../../../src/shared/native-chat-transcript-retention' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' @@ -28,9 +32,6 @@ export type MobileNativeChatSession = { loadEarlier: () => void } -// Stable empty reference so a not-yet-current read doesn't churn consumers. -const EMPTY_MESSAGES: NativeChatMessage[] = [] - // Small first page for a fast first paint; grows by a page as the user scrolls. const INITIAL_LIMIT = 40 const PAGE = 60 @@ -45,13 +46,20 @@ type ReadSessionResult = * an ordered tail); live appends merge by id so order stays stable. */ export function useMobileNativeChatSession(args: { client: RpcClient | null + /** Stable host/workspace source; unlike `client`, it survives manual reconnect. */ + sourceIdentity: string agent: string | null sessionId: string | null transcriptPath: string | null }): MobileNativeChatSession { - const { client, agent, sessionId, transcriptPath } = args + const { client, sourceIdentity, agent, sessionId, transcriptPath } = args const [messages, setMessages] = useState([]) - const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}` + const identity = encodeNativeChatTranscriptIdentity([ + sourceIdentity, + agent, + sessionId, + transcriptPath + ]) // Pre-read status is a pure function of the props, so derive it rather than // letting the effect write it a commit later. const initialStatus: MobileNativeChatStatus = @@ -95,6 +103,13 @@ export function useMobileNativeChatSession(args: { // Whether this subscription already delivered its base snapshot; later // snapshots on the same subscription are reconnect replays, not fresh bases. const snapshotSeenRef = useRef(false) + const transcriptRetentionRef = useRef(createNativeChatTranscriptRetention()) + const settledReady = settled?.status === 'ready' + useEffect(() => { + if (settledReady) { + transcriptRetentionRef.current.capture(identity, messages) + } + }, [identity, messages, settledReady]) // Replace the base list (read results are an ordered tail). Resets the merger // cache so the index is rebuilt once over the new base. @@ -258,10 +273,17 @@ export function useMobileNativeChatSession(args: { })() }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + const visibleMessages = transcriptRetentionRef.current.visible({ + identity, + messages, + settled: settledReady, + loading: status === 'loading' + }) + return { // Withheld until the settled read belongs to this identity: the effect that // clears the previous tab's list is passive, so `messages` lags a commit. - messages: settled ? messages : EMPTY_MESSAGES, + messages: visibleMessages, status, transcriptLoading: status === 'loading', error, diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts new file mode 100644 index 00000000000..bb76d2df75b --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWith(sendRequest: RpcClient['sendRequest']): Pick { + return { sendRequest } +} + +function options(sendRequest: RpcClient['sendRequest']) { + return { + client: clientWith(sendRequest), + worktreeId: 'worktree-1', + relativePath: 'src/target.ts', + tabMode: 'diff' as const, + staged: true + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('revealMobileSourceControlSessionDiff', () => { + it('activates the requested file when its diff tab is already open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + activeTabId: 'agent-tab', + tabs: [ + { id: 'agent-tab', type: 'terminal' }, + { + id: 'other-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/other.ts' + }, + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', { + worktree: 'id:worktree-1', + tabId: 'target-diff', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + }) + + it('selects the staged diff when both versions of a file are open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + }, + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('waits for the requested diff source instead of activating the other one', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('activates a legacy edit tab when opening a diff falls back to files.open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'stale-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + }, + { + id: 'target-edit', + type: 'file', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-edit' })) + + await expect( + revealMobileSourceControlSessionDiff({ ...options(sendRequest), tabMode: 'edit' }) + ).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'target-edit' }) + ) + }) + + it('retries until the opened diff appears in the session snapshot', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success({ tabs: [{ id: 'agent-tab', type: 'terminal' }] })) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenCalledTimes(3) + }) + + it('delegates to the mounted session when the dock callback is available', async () => { + const sendRequest = vi.fn() + const onOpenedFileDiff = vi.fn() + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + onOpenedFileDiff + }) + ).resolves.toBe('revealed') + + expect(onOpenedFileDiff).toHaveBeenCalledWith('src/target.ts') + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels route-owned polling after the source-control screen unmounts', async () => { + let current = true + const sendRequest = vi.fn().mockImplementation(async () => { + current = false + return success({ tabs: [] }) + }) + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + isCurrent: () => current + }) + ).resolves.toBe('cancelled') + expect(sendRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts new file mode 100644 index 00000000000..9391c0c2961 --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts @@ -0,0 +1,143 @@ +import type { RpcClient } from '../transport/rpc-client' +import { activateMobileSessionTab } from '../session/mobile-session-tab-activation' + +type ActivationClient = Pick + +type SessionFileTabCandidate = { + id: string + type: string + mode?: unknown + relativePath?: unknown + diffSource?: unknown +} + +type Options = { + client: ActivationClient + worktreeId: string + relativePath: string + tabMode: 'diff' | 'edit' + staged: boolean + onOpenedFileDiff?: (relativePath: string) => void + isCurrent?: () => boolean +} + +export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout' + +const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const + +export async function revealMobileSourceControlSessionDiff( + options: Options +): Promise { + if (options.onOpenedFileDiff) { + options.onOpenedFileDiff(options.relativePath) + return 'revealed' + } + + for (const delayMs of TAB_POLL_DELAYS_MS) { + await waitForDelay(delayMs) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + + const tab = await findOpenedSessionFileTab(options) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (!tab) { + continue + } + + const activated = await activateSessionFileTab(options, tab.id) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (activated) { + return 'revealed' + } + } + + return 'timeout' +} + +async function findOpenedSessionFileTab(options: Options): Promise { + try { + const response = await options.client.sendRequest('session.tabs.list', { + worktree: `id:${options.worktreeId}` + }) + if (!response.ok) { + return null + } + const snapshot = readTabSnapshot(response.result) + if (!snapshot) { + return null + } + + const matches = snapshot.tabs.filter( + (tab) => + tab.type !== 'browser' && + tab.type !== 'terminal' && + matchesTabMode(tab.mode, options.tabMode) && + tab.relativePath === options.relativePath + ) + if (options.tabMode === 'edit') { + return matches[0] ?? null + } + const source = options.staged ? 'staged' : 'unstaged' + return ( + matches.find((tab) => tab.diffSource === source) ?? + matches.find((tab) => tab.diffSource == null) ?? + null + ) + } catch { + return null + } +} + +function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean { + return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null +} + +async function activateSessionFileTab(options: Options, tabId: string): Promise { + try { + const response = await activateMobileSessionTab(options.client, { + worktree: `id:${options.worktreeId}`, + tabId, + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + return response.ok && readActiveTabId(response.result) === tabId + } catch { + return false + } +} + +function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null { + if ( + !isRecord(value) || + !Array.isArray(value.tabs) || + !value.tabs.every(isSessionFileTabCandidate) + ) { + return null + } + return { tabs: value.tabs } +} + +function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} + +function readActiveTabId(value: unknown): string | null { + return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function waitForDelay(delayMs: number): Promise { + if (delayMs === 0) { + return + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index bf0eff63cf6..f6c44825715 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -18,6 +18,7 @@ import { type MobileGitStatusEntry } from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' import type { GitDiffTextResult, MobileBranchCompareState, @@ -115,11 +116,13 @@ export function useMobileSourceControlOpeners(params: Params) { relativePath: entry.path, staged: entry.area === 'staged' }) + let openedTabMode: 'diff' | 'edit' = 'diff' if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) { response = await client.sendRequest('files.open', { worktree: `id:${worktreeId}`, relativePath: entry.path }) + openedTabMode = 'edit' } if (!response.ok) { throw new Error(response.error?.message || 'Unable to open diff') @@ -127,8 +130,22 @@ export function useMobileSourceControlOpeners(params: Params) { if (!mountedRef.current) { return } + const revealResult = await revealMobileSourceControlSessionDiff({ + client, + worktreeId, + relativePath: entry.path, + tabMode: openedTabMode, + staged: entry.area === 'staged', + onOpenedFileDiff, + isCurrent: () => mountedRef.current && openingPathRef.current === entry.path + }) + if (revealResult === 'cancelled') { + return + } + if (revealResult === 'timeout') { + throw new Error("The file opened, but its tab isn't ready yet. Try again.") + } triggerSelection() - onOpenedFileDiff?.(entry.path) // Why: when launched from the session screen, opening a file dismisses // this surface back to the session. In embedded mode there is nothing // to pop (the panel docks beside the terminal), so close the dock diff --git a/mobile/src/tasks/github-project-repo-match.test.ts b/mobile/src/tasks/github-project-repo-match.test.ts index bce71393dab..58c44a57830 100644 --- a/mobile/src/tasks/github-project-repo-match.test.ts +++ b/mobile/src/tasks/github-project-repo-match.test.ts @@ -115,6 +115,136 @@ describe('GitHub project repo matching', () => { ).toBe(repos[1]) }) + it('matches an upstream project row against a fork clone', () => { + const fork = { + id: 'repo-1', + path: '/Users/me/r2r-mirror', + displayName: 'r2r-mirror', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + } + + expect( + findRepoForGitHubProjectRepository('SciPhi-AI/R2R', [fork], { + 'repo-1': { + path: '/Users/me/r2r-mirror', + repository: { owner: 'me', repo: 'r2r-mirror' } + } + }) + ).toBe(fork) + }) + + it('prefers the clone that owns the slug over a fork of it', () => { + const upstreamClone = { id: 'repo-1', path: '/Users/me/r2r', displayName: 'r2r' } + const fork = { + id: 'repo-2', + path: '/Users/me/r2r-mirror', + displayName: 'r2r-mirror', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + } + + expect( + findRepoForGitHubProjectRepository('SciPhi-AI/R2R', [upstreamClone, fork], { + 'repo-1': { + path: '/Users/me/r2r', + repository: { owner: 'SciPhi-AI', repo: 'R2R' } + }, + 'repo-2': { + path: '/Users/me/r2r-mirror', + repository: { owner: 'me', repo: 'r2r-mirror' } + } + }) + ).toBe(upstreamClone) + }) + + it('does not pick a repo when two forks share the same upstream', () => { + const forks = [ + { + id: 'repo-1', + path: '/Users/me/a', + displayName: 'a', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + }, + { + id: 'repo-2', + path: '/Users/me/b', + displayName: 'b', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + } + ] + + expect( + findRepoForGitHubProjectRepository('SciPhi-AI/R2R', forks, { + 'repo-1': { path: '/Users/me/a', repository: { owner: 'me', repo: 'a' } }, + 'repo-2': { path: '/Users/me/b', repository: { owner: 'me', repo: 'b' } } + }) + ).toBeNull() + }) + + it('does not bind a github.com fork parent to a same-named Enterprise row', () => { + const fork = { + id: 'repo-1', + path: '/Users/me/r2r-mirror', + displayName: 'r2r-mirror', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + } + + expect( + findRepoForGitHubProjectRepository( + 'SciPhi-AI/R2R', + [fork], + { + 'repo-1': { + path: '/Users/me/r2r-mirror', + repository: { owner: 'me', repo: 'r2r-mirror', host: 'github.com' } + } + }, + 'github.acme-corp.com' + ) + ).toBeNull() + }) + + it('drops the fork alias while its own origin is unresolved', () => { + const fork = { + id: 'repo-1', + path: '/Users/me/widgets-mirror', + displayName: 'widgets-mirror', + upstream: { owner: 'acme', repo: 'widgets' } + } + + for (const slugs of [ + {}, + { 'repo-1': { path: '/Users/me/widgets-mirror', repository: null } }, + { 'repo-1': { path: '/moved', repository: { owner: 'me', repo: 'widgets' } } } + ]) { + expect(findRepoForGitHubProjectRepository('acme/widgets', [fork], slugs)).toBeNull() + } + }) + + it('scopes a host-less fork parent to the host the fork itself was cloned from', () => { + const enterpriseFork = { + id: 'repo-1', + path: '/Users/me/widgets-mirror', + displayName: 'widgets-mirror', + upstream: { owner: 'acme', repo: 'widgets' } + } + const slugs = { + 'repo-1': { + path: '/Users/me/widgets-mirror', + repository: { owner: 'me', repo: 'widgets', host: 'github.acme-corp.com' } + } + } + + expect( + findRepoForGitHubProjectRepository( + 'acme/widgets', + [enterpriseFork], + slugs, + 'github.acme-corp.com' + ) + ).toBe(enterpriseFork) + expect(findRepoForGitHubProjectRepository('acme/widgets', [enterpriseFork], slugs)).toBeNull() + }) + it('does not use hostless path heuristics for Enterprise Project rows', () => { expect( findRepoForGitHubProjectRepository( diff --git a/mobile/src/tasks/github-project-repo-match.ts b/mobile/src/tasks/github-project-repo-match.ts index c4d1e1d2ea6..1e9cd61996a 100644 --- a/mobile/src/tasks/github-project-repo-match.ts +++ b/mobile/src/tasks/github-project-repo-match.ts @@ -7,6 +7,9 @@ export type GitHubProjectRepoMatch = { id: string path: string displayName: string + /** Fork parent resolved by the host and carried on `repo.list`. Absent = not + * a fork or not yet resolved. */ + upstream?: { owner: string; repo: string; host?: string } | null } export type GitHubRepoSlugCacheEntry = { @@ -45,6 +48,27 @@ function cachedSlugStateForRepo( return { status: 'resolved', repository: cached.repository } } +/** Identity key of the repo's fork parent, or null when it is not a fork or its + * origin has not resolved. Why: when `upstream.host` is absent (older persisted + * forks), the fork's origin host is the fallback so GHES parents do not collapse + * into github.com. Unresolved origins refuse the alias. */ +function upstreamIdentityKeyForRepo( + repo: GitHubProjectRepoMatch, + originState: CachedSlugState | undefined +): string | null { + const upstream = repo.upstream + if (!upstream?.owner || !upstream.repo) { + return null + } + if (originState?.status !== 'resolved' || !originState.repository) { + return null + } + return githubRepoIdentityKey({ + ...upstream, + host: upstream.host ?? originState.repository.host + }) +} + export function findRepoForGitHubProjectRepository( repository: string | null | undefined, repos: GitHubProjectRepoMatch[], @@ -79,6 +103,20 @@ export function findRepoForGitHubProjectRepository( return null } + // Why: a Project card references the upstream repo, but a contributor's clone + // has their personal fork as `origin`, so origin-only matching hid every row + // (#12647). Checked after origin so an open clone of the upstream repo itself + // always wins over someone's fork of it. + const upstreamMatches = repos.filter( + (repo) => upstreamIdentityKeyForRepo(repo, slugStates.get(repo.id)) === requestedIdentityKey + ) + if (upstreamMatches.length === 1) { + return upstreamMatches[0]! + } + if (upstreamMatches.length > 1) { + return null + } + if (!isDefaultGitHubHost(projectHost)) { // Why: display names and local paths contain no host evidence, so using // them for GHES rows could bind an Enterprise item to a github.com repo. diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts index 5bc519578ba..bacc24be5b8 100644 --- a/mobile/src/tasks/mobile-task-navigation.test.ts +++ b/mobile/src/tasks/mobile-task-navigation.test.ts @@ -32,8 +32,9 @@ describe('mobile task navigation', () => { it('waits for the expected host commit before navigating to Tasks', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push }, 'host/1') + navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1') expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan( push.mock.invocationCallOrder[0]! @@ -46,38 +47,25 @@ describe('mobile task navigation', () => { routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() - - harness.setState({ - index: 1, - routes: [ - { name: 'index' }, - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }] - } - } - ] - }) - expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan( - harness.navigation.dispatch.mock.invocationCallOrder[0]! + replace.mock.invocationCallOrder[0]! ) - expect(harness.navigation.dispatch).toHaveBeenCalledWith({ - type: 'REPLACE', - target: '/h', - source: 'host-index', - payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } } + expect(replace).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/tasks', + params: { hostId: 'host/1' } }) }) it('ignores unrelated state events and preserves the provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear') + navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1', + 'linear' + ) harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) @@ -108,30 +96,37 @@ describe('mobile task navigation', () => { it('cleanup prevents a stale navigation from replacing', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') + const replace = vi.fn() + const controller = navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace }, + 'host-1' + ) controller.cancel() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('reuses a pending host push and applies the latest provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const router = { push, replace: vi.fn() } const first = coordinateMobileTasksNavigation( null, harness.navigation, - { push }, + router, 'host-1', 'github' ) const second = coordinateMobileTasksNavigation( first, harness.navigation, - { push }, + router, 'host-1', 'linear' ) @@ -161,11 +156,12 @@ describe('mobile task navigation', () => { ) }) - it('keeps waiting through host setup and cleans up when navigation leaves', () => { + it('keeps waiting through host setup without params and cleans up when navigation leaves', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) + navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1') + harness.setState({ index: 0, routes: [{ name: 'h' }] }) expect(harness.unsubscribeState).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState({ @@ -184,6 +180,7 @@ describe('mobile task navigation', () => { expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('unsubscribes when mounting the host throws synchronously', () => { @@ -196,7 +193,8 @@ describe('mobile task navigation', () => { { push: () => { throw error - } + }, + replace: vi.fn() }, 'host-1' ) diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 0125cb053e6..ce360091ce2 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -39,6 +39,7 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') + expect(block).toContain('liveInputText={liveInputCapture}') expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') expect(sessionRouteSource).toContain('return resetLiveInputFocus') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') @@ -52,6 +53,8 @@ describe('terminal live input affordance', () => { it('makes the live keyboard target visible instead of status-only chrome', () => { expect(liveInputStatusSource).toContain("'Tap to show keyboard'") + expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'") + expect(liveInputStatusSource).toContain('ellipsizeMode="head"') expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') expect(commandInputStylesSource).toContain('borderWidth: 1') expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 88918c00681..a77d4315c29 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from 'vitest' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, - waitForTerminalLivePendingFlush, - type TerminalLivePendingFlushState + waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' describe('terminal live pending flush state', () => { it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When / Then await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) @@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => { }) describe('terminal live mirror send queue', () => { + it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => { + // Given + const state = createTerminalLivePendingFlushState() + const payloads: string[] = [] + let resolveFirstSend: (value: boolean) => void = () => {} + const sender = async (_handle: string, payload: string): Promise => { + payloads.push(payload) + if (payloads.length === 1) { + return new Promise((resolve) => { + resolveFirstSend = resolve + }) + } + return true + } + + // When + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender) + await Promise.resolve() + + // Then + expect(payloads).toEqual(['a']) + resolveFirstSend(true) + await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true]) + expect(payloads).toEqual(['a', 'bc']) + }) + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() const order: string[] = [] - const first = queueTerminalLiveMirrorSend(state, async () => { + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { order.push('first') return false }) // When - const second = queueTerminalLiveMirrorSend(state, async () => { + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => { order.push('second') return true }) @@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => { it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } - const first = queueTerminalLiveMirrorSend(state, async () => { + const state = createTerminalLivePendingFlushState() + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { throw new Error('boom') }) // When - const second = queueTerminalLiveMirrorSend(state, async () => true) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true) // Then await expect(first).resolves.toBe(false) @@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => { it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When - await queueTerminalLiveMirrorSend(state, async () => true) + await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true) await Promise.resolve() // Then expect(state.current).toBeNull() }) + + it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => { + // Given + const state = createTerminalLivePendingFlushState() + let resolveSend: (value: boolean) => void = () => {} + const sender = async (): Promise => + new Promise((resolve) => { + resolveSend = resolve + }) + const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + + // When + cancelTerminalLivePendingFlush(state) + + // Then + await expect(Promise.all([active, pending])).resolves.toEqual([false, false]) + expect(state.current).toBeNull() + resolveSend(true) + }) }) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index 405198964f3..fe0b4927f5a 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -1,5 +1,30 @@ +type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise + +type TerminalLivePendingRequest = { + readonly resolve: (sent: boolean) => void +} + +type TerminalLivePendingBatch = { + readonly handle: string + payload: string + readonly requests: TerminalLivePendingRequest[] + readonly sender: TerminalLiveMirrorSender +} + export type TerminalLivePendingFlushState = { current: Promise | null + activeRequests: TerminalLivePendingRequest[] + generation: number + pendingBatches: TerminalLivePendingBatch[] +} + +export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState { + return { + current: null, + activeRequests: [], + generation: 0, + pendingBatches: [] + } } export function waitForTerminalLivePendingFlush( @@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush( return state.current ?? Promise.resolve(true) } -// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped -// delta desyncs every later diff, so this chain runs each send even when the -// previous one failed. state.current should never reject; the catch keeps a -// future raw assignment from skipping a delta. +export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void { + state.generation += 1 + const requests = [ + ...state.activeRequests, + ...state.pendingBatches.flatMap((batch) => batch.requests) + ] + state.activeRequests = [] + state.pendingBatches = [] + state.current = null + requests.forEach(({ resolve }) => resolve(false)) +} + +async function drainTerminalLiveMirrorSends( + state: TerminalLivePendingFlushState, + generation: number +): Promise { + let allSent = true + while (state.generation === generation) { + const batch = state.pendingBatches.shift() + if (!batch) { + state.current = null + return allSent + } + + state.activeRequests = batch.requests + const sent = await batch.sender(batch.handle, batch.payload).catch(() => false) + if (state.generation !== generation) { + return false + } + + state.activeRequests = [] + batch.requests.forEach(({ resolve }) => resolve(sent)) + allSent &&= sent + } + return false +} + +// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke. export function queueTerminalLiveMirrorSend( state: TerminalLivePendingFlushState, - sendMirrorPayload: () => Promise + handle: string, + payload: string, + sender: TerminalLiveMirrorSender ): Promise { - const previousSend = state.current - const sendPromise = (async () => { - if (previousSend) { - await previousSend.catch(() => false) - } - return sendMirrorPayload() - })().catch(() => false) - state.current = sendPromise - void sendPromise.then(() => { - if (state.current === sendPromise) { - state.current = null - } + let resolveRequest: (sent: boolean) => void = () => {} + const request = new Promise((resolve) => { + resolveRequest = resolve }) - return sendPromise + const pendingTail = state.pendingBatches.at(-1) + if (pendingTail?.handle === handle && pendingTail.sender === sender) { + pendingTail.payload += payload + pendingTail.requests.push({ resolve: resolveRequest }) + } else { + state.pendingBatches.push({ + handle, + payload, + requests: [{ resolve: resolveRequest }], + sender + }) + } + + if (!state.current) { + const generation = state.generation + const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false) + state.current = drain + void drain.then(() => { + if (state.current === drain) { + state.current = null + } + }) + } + return request } diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index f6239312947..05617442142 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -7,6 +7,8 @@ import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush({ setLiveInputCapture }: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { const heldCommitTimerRef = useRef | null>(null) - const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState()) const heldLiveInputTextRef = useRef('') const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) @@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush({ const resetMirrorState = useCallback(() => { clearHeldCommitTimer() + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null @@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush({ }, [liveInputRef, resetMirrorState, setLiveInputCapture]) const waitForPendingLiveInputFlush = useCallback(async (): Promise => { - return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current) }, []) + const sendQueuedMirrorPayload = useCallback( + (handle: string, payload: string): Promise => + sendLiveTerminalInputRef.current(handle, payload), + [sendLiveTerminalInputRef] + ) + const runMirrorStep = useCallback( async (handle: string, fieldText: string, commitHeld: boolean): Promise => { if ( @@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush({ if (payload.length === 0) { return waitForPendingLiveInputFlush() } - return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () => - sendLiveTerminalInputRef.current(handle, payload) + return queueTerminalLiveMirrorSend( + pendingLiveInputFlushRef.current, + handle, + payload, + sendQueuedMirrorPayload ) }, [ @@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush({ clearHeldCommitTimer, liveInputTerminalHandlesRef, resetMirrorState, - sendLiveTerminalInputRef, + sendQueuedMirrorPayload, waitForPendingLiveInputFlush ] ) @@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush({ heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null - pendingLiveInputFlushRef.current = null + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) } }, []) diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index e7c00008e05..b08899f5333 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -21,6 +21,8 @@ vi.mock('./connection-revival-triggers', () => ({ })) import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context' +import { useAllHostClients } from './use-all-host-clients' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' type FakeClient = RpcClient & { emitState: (state: ConnectionState) => void @@ -386,3 +388,179 @@ describe('useHostClient', () => { expect(connectMock).not.toHaveBeenCalled() }) }) + +describe('useAllHostClients', () => { + it('only opens the requested startup subset', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id], { autoConnectHostIds: [host2.id] }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledOnce() + expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function)) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('keeps startup connection fanout constant for a large saved-host list', async () => { + const hosts = Array.from({ length: 1_000 }, (_, index) => ({ + ...HOST, + id: `host-${index}`, + name: `Host ${index}`, + lastConnected: index + })) + const hostIds = hosts.map((host) => host.id) + const autoConnectHostIds = selectHomeAutoConnectHostIds(hosts) + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue(hosts) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients(hostIds, { autoConnectHostIds }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(3) + expect(connectMock.mock.calls.map(([host]) => host.id)).toEqual([ + 'host-999', + 'host-998', + 'host-997' + ]) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('closes a demoted Home client when the recent-host set rotates', async () => { + const hosts = [ + { ...HOST, id: 'host-a', lastConnected: 4 }, + { ...HOST, id: 'host-b', lastConnected: 3 }, + { ...HOST, id: 'host-c', lastConnected: 2 }, + { ...HOST, id: 'host-d', lastConnected: 1 } + ] + const clients = new Map() + connectMock.mockImplementation((profile: typeof HOST) => { + const client = makeFakeClient('connected') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(hosts) + + let activeHostIds: string[] = [] + let renderer: ReactTestRenderer | null = null + function Probe({ profiles }: { profiles: typeof hosts }): null { + const hostIds = profiles.map((host) => host.id) + activeHostIds = useAllHostClients(hostIds, { + autoConnectHostIds: selectHomeAutoConnectHostIds(profiles), + closeUnusedOnRelease: true + }).map(({ hostId }) => hostId) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(Probe, { profiles: hosts })) + ) + await Promise.resolve() + }) + expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-c']) + + const rotatedHosts = hosts.map((host) => + host.id === 'host-d' ? { ...host, lastConnected: 5 } : host + ) + await act(async () => { + renderer?.update( + createElement(RpcClientProvider, null, createElement(Probe, { profiles: rotatedHosts })) + ) + await Promise.resolve() + }) + + expect(connectMock).toHaveBeenCalledTimes(4) + expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-d']) + expect(clients.get('host-a')?.closeMock).not.toHaveBeenCalled() + expect(clients.get('host-b')?.closeMock).not.toHaveBeenCalled() + expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce() + expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('retains connect-all behavior when no startup subset is provided', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id]) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(2) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('allows an excluded host to connect manually', async () => { + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST]) + + let reconnect: ((hostId: string) => Promise) | null = null + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id], { autoConnectHostIds: [] }) + reconnect = useForceReconnect() + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(connectMock).not.toHaveBeenCalled() + if (!reconnect) { + throw new Error('reconnect harness did not initialize') + } + await act(async () => { + await reconnect?.(HOST.id) + }) + expect(connectMock).toHaveBeenCalledOnce() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) +}) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 8c00a11b2be..18ee0b7782a 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -14,9 +14,20 @@ import type { RpcClient } from './rpc-client' import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { HostClientOpenRegistry } from './host-client-open-registry' +import { decrementPendingAcquisition } from './host-client-acquisition-count' +import { + clientActivePath, + listHostClients, + notifyAllHostListeners, + notifyHostStateListeners, + primeHostProfiles, + subscribeAllHostListener, + subscribeHostStateListener, + type CloseEntryOptions +} from './host-client-context-state' import { loadHosts } from './host-store' import { openHostLogicalClient } from './host-logical-client' -import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState, HostProfile } from './types' type StoreEntry = { @@ -29,6 +40,8 @@ type StoreEntry = { export type RpcClientContextValue = { acquire: (hostId: string, host?: HostProfile) => RpcClient | null release: (hostId: string) => void + releaseAndCloseIfUnused: (hostId: string) => void + closeIfUnused: (hostId: string) => void forceReconnect: (hostId: string) => Promise closeHost: (hostId: string) => void getState: (hostId: string) => ConnectionState @@ -55,30 +68,27 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { // Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup. const pendingOpensRef = useRef(new HostClientOpenRegistry()) + const pendingAcquisitionsRef = useRef>(new Map()) // Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start. const primedHostsRef = useRef>(new Map()) - function notifyHostState(hostId: string, state: ConnectionState) { - const set = stateListenersRef.current.get(hostId) - if (!set) { - return - } - for (const listener of set) { - listener(state) - } - } + const notifyHostState = (hostId: string, state: ConnectionState) => + notifyHostStateListeners(stateListenersRef.current, hostId, state) + const notifyAllHosts = () => notifyAllHostListeners(allHostsListenersRef.current) - function notifyAllHosts() { - for (const listener of allHostsListenersRef.current) { - listener() - } - } - - const closeEntry = useCallback((hostId: string) => { - pendingOpensRef.current.cancel(hostId) - primedHostsRef.current.delete(hostId) + const closeEntry = useCallback((hostId: string, options: CloseEntryOptions) => { const entry = storeRef.current.get(hostId) + const acquisitionCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0 + pendingOpensRef.current.cancel(hostId) + if (options.preserveAcquisitions && acquisitionCount > 0) { + pendingAcquisitionsRef.current.set(hostId, acquisitionCount) + } else { + pendingAcquisitionsRef.current.delete(hostId) + } + if (options.forgetPrimedHost) { + primedHostsRef.current.delete(hostId) + } entry?.unsubState() storeRef.current.delete(hostId) entry?.client.close() @@ -86,6 +96,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { notifyAllHosts() }, []) + const closeHost = useCallback( + (hostId: string) => { + closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true }) + }, + [closeEntry] + ) + const openEntry = useCallback(async (hostId: string): Promise => { const existing = pendingOpensRef.current.getActivePromise(hostId) if (existing) { @@ -151,9 +168,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const entry: StoreEntry = { client, state: client.getState(), - refCount: 0, + refCount: pendingAcquisitionsRef.current.get(hostId) ?? 0, unsubState } + pendingAcquisitionsRef.current.delete(hostId) storeRef.current.set(hostId, entry) notifyHostState(hostId, entry.state) notifyAllHosts() @@ -175,50 +193,83 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { existing.refCount += 1 return existing.client } + pendingAcquisitionsRef.current.set( + hostId, + (pendingAcquisitionsRef.current.get(hostId) ?? 0) + 1 + ) // Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes. - void openEntry(hostId).then((entry) => { - if (!entry) { - return - } - entry.refCount += 1 - }) + void openEntry(hostId) return null }, [openEntry] ) - const primeHosts = useCallback((hosts: HostProfile[]) => { - for (const host of hosts) { - primedHostsRef.current.set(host.id, host) - } - }, []) + const primeHosts = useCallback( + (hosts: HostProfile[]) => primeHostProfiles(primedHostsRef.current, hosts), + [] + ) // Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded. const release = useCallback((hostId: string) => { const entry = storeRef.current.get(hostId) - if (!entry) { + if (entry) { + entry.refCount = Math.max(0, entry.refCount - 1) return } - entry.refCount = Math.max(0, entry.refCount - 1) + decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) }, []) + const releaseAndCloseIfUnused = useCallback( + (hostId: string) => { + const entry = storeRef.current.get(hostId) + if (entry) { + entry.refCount = Math.max(0, entry.refCount - 1) + if (entry.refCount === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + return + } + if (decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + }, + [closeEntry] + ) + + const closeIfUnused = useCallback( + (hostId: string) => { + const entry = storeRef.current.get(hostId) + const pendingCount = pendingAcquisitionsRef.current.get(hostId) + const hasPendingOpen = pendingOpensRef.current.getActivePromise(hostId) !== null + if (!entry && pendingCount === undefined && !hasPendingOpen) { + return + } + if ((entry?.refCount ?? pendingCount ?? 0) === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + }, + [closeEntry] + ) + const forceReconnect = useCallback( async (hostId: string) => { const entry = storeRef.current.get(hostId) - // Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count. - const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 - const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) + // Why: ownership survives explicit close/re-pair while observers never become synthetic owners. + const savedRefCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0 if (entry) { entry.unsubState() entry.client.close() storeRef.current.delete(hostId) } + if (savedRefCount > 0) { + pendingAcquisitionsRef.current.set( + hostId, + Math.max(savedRefCount, pendingAcquisitionsRef.current.get(hostId) ?? 0) + ) + } // Why: Retry must read amber for the whole reopen, not grey-then-amber. notifyHostState(hostId, 'connecting') - const fresh = await openEntry(hostId) - if (fresh) { - fresh.refCount = savedRefCount - } + await openEntry(hostId) }, [openEntry] ) @@ -252,41 +303,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { }, []) const subscribeHostState = useCallback( - (hostId: string, listener: (state: ConnectionState) => void) => { - let set = stateListenersRef.current.get(hostId) - if (!set) { - set = new Set() - stateListenersRef.current.set(hostId, set) - } - set.add(listener) - return () => { - const s = stateListenersRef.current.get(hostId) - if (!s) { - return - } - s.delete(listener) - if (s.size === 0) { - stateListenersRef.current.delete(hostId) - } - } - }, + (hostId: string, listener: (state: ConnectionState) => void) => + subscribeHostStateListener(stateListenersRef.current, hostId, listener), [] ) - const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => { - const out: Array<{ hostId: string; client: RpcClient }> = [] - for (const [hostId, entry] of storeRef.current) { - out.push({ hostId, client: entry.client }) - } - return out - }, []) + const getAllClients = useCallback(() => listHostClients(storeRef.current), []) - const subscribeAllHosts = useCallback((listener: () => void) => { - allHostsListenersRef.current.add(listener) - return () => { - allHostsListenersRef.current.delete(listener) - } - }, []) + const subscribeAllHosts = useCallback( + (listener: () => void) => subscribeAllHostListener(allHostsListenersRef.current, listener), + [] + ) // Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -294,8 +321,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const store = storeRef.current return () => { pendingOpensRef.current.cancelAll() + pendingAcquisitionsRef.current.clear() for (const [hostId] of store) { - closeEntry(hostId) + closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false }) } } }, []) @@ -313,8 +341,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { () => ({ acquire, release, + releaseAndCloseIfUnused, + closeIfUnused, forceReconnect, - closeHost: closeEntry, + closeHost, getState, getKnownState, getReconnectAttempt, @@ -328,8 +358,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { [ acquire, release, + releaseAndCloseIfUnused, + closeIfUnused, forceReconnect, - closeEntry, + closeHost, getState, getKnownState, getReconnectAttempt, @@ -437,13 +469,3 @@ export function usePrimeHosts(): (hosts: HostProfile[]) => void { const ctx = useRpcClientContext() return ctx.primeHosts } - -function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { - const logical = client as Partial | undefined - if (typeof logical?.getActivePath !== 'function') { - return 'lan' - } - // Why: mid-migration the active path still names the session being replaced; the - // pending one is what the user is actually waiting on (F5). - return logical.getPendingPath?.() ?? logical.getActivePath() -} diff --git a/mobile/src/transport/home-host-auto-connect.test.ts b/mobile/src/transport/home-host-auto-connect.test.ts new file mode 100644 index 00000000000..ecf04934809 --- /dev/null +++ b/mobile/src/transport/home-host-auto-connect.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { HostProfile } from './types' +import { + HOME_AUTO_CONNECT_LIMIT, + resolveHomeHostConnectionState, + selectHomeAutoConnectHostIds +} from './home-host-auto-connect' + +function host(id: string, lastConnected: number, credentials = true): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}`, + deviceToken: credentials ? `token-${id}` : '', + publicKeyB64: credentials ? `key-${id}` : '', + lastConnected + } +} + +describe('home host auto-connect', () => { + it('limits startup connections to the most recently used credentialed hosts', () => { + const hosts = [ + host('old', 1), + host('newest', 5), + host('second', 4), + host('third', 3), + host('fourth', 2), + host('missing-credentials', 6, false) + ] + + expect(selectHomeAutoConnectHostIds(hosts)).toEqual(['newest', 'second', 'third']) + expect(selectHomeAutoConnectHostIds(hosts)).toHaveLength(HOME_AUTO_CONNECT_LIMIT) + }) + + it('does not mutate the host card order', () => { + const hosts = [host('old', 1), host('new', 2)] + + selectHomeAutoConnectHostIds(hosts) + + expect(hosts.map((item) => item.id)).toEqual(['old', 'new']) + }) + + it('only presents hosts in the startup subset as connecting before clients open', () => { + const autoConnectHostIds = ['recent'] + + expect(resolveHomeHostConnectionState('recent', undefined, autoConnectHostIds)).toBe( + 'connecting' + ) + expect(resolveHomeHostConnectionState('stale', undefined, autoConnectHostIds)).toBe( + 'disconnected' + ) + expect(resolveHomeHostConnectionState('stale', 'connected', autoConnectHostIds)).toBe( + 'connected' + ) + }) +}) diff --git a/mobile/src/transport/home-host-auto-connect.ts b/mobile/src/transport/home-host-auto-connect.ts new file mode 100644 index 00000000000..995d653a0df --- /dev/null +++ b/mobile/src/transport/home-host-auto-connect.ts @@ -0,0 +1,24 @@ +import type { ConnectionState, HostProfile } from './types' + +export const HOME_AUTO_CONNECT_LIMIT = 3 + +export function selectHomeAutoConnectHostIds( + hosts: readonly HostProfile[], + limit = HOME_AUTO_CONNECT_LIMIT +): string[] { + return [...hosts] + .filter((host) => host.deviceToken.length > 0 && host.publicKeyB64.length > 0) + .sort( + (left, right) => right.lastConnected - left.lastConnected || left.id.localeCompare(right.id) + ) + .slice(0, Math.max(0, limit)) + .map((host) => host.id) +} + +export function resolveHomeHostConnectionState( + hostId: string, + state: ConnectionState | undefined, + autoConnectHostIds: readonly string[] +): ConnectionState { + return state ?? (autoConnectHostIds.includes(hostId) ? 'connecting' : 'disconnected') +} diff --git a/mobile/src/transport/host-catalog-credential-join.ts b/mobile/src/transport/host-catalog-credential-join.ts new file mode 100644 index 00000000000..e914742ff4e --- /dev/null +++ b/mobile/src/transport/host-catalog-credential-join.ts @@ -0,0 +1,52 @@ +import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' +import type { HostListSnapshot } from './host-list-load-sharing' +import type { HostCatalogEntry, StoredHostProfile } from './types' + +export async function joinHostCatalogCredentials(args: { + storedHosts: readonly StoredHostProfile[] + overlays: ReadonlyMap + tokenCache: Map + readToken: (hostId: string) => Promise + getRevision: () => number +}): Promise { + const catalog: HostCatalogEntry[] = [] + const profiles: HostListSnapshot['profiles'] = [] + for (const stored of args.storedHosts) { + let token = args.tokenCache.get(stored.id) + let credentialStatus: HostCatalogEntry['credentialStatus'] = 'ready' + if (!token) { + const readRevision = args.getRevision() + let fetched: string | null + try { + fetched = await args.readToken(stored.id) + } catch { + credentialStatus = 'temporarily-unavailable' + fetched = null + } + if (!fetched && credentialStatus === 'ready') { + credentialStatus = 'missing' + } + token = fetched ?? undefined + if (token && readRevision === args.getRevision()) { + args.tokenCache.set(stored.id, token) + } + } + const overlay = args.overlays.get(stored.id) + const base = { + ...stored, + ...(overlay + ? { + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + } + : {}) + } + const profile = token ? { ...base, deviceToken: token } : null + catalog.push({ ...base, credentialStatus, profile }) + if (profile) { + profiles.push(profile) + } + } + return { catalog, profiles } +} diff --git a/mobile/src/transport/host-catalog-selection.test.ts b/mobile/src/transport/host-catalog-selection.test.ts new file mode 100644 index 00000000000..5f5d85ca787 --- /dev/null +++ b/mobile/src/transport/host-catalog-selection.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { selectConnectableHostProfiles } from './host-catalog-selection' +import type { HostCatalogEntry, HostProfile } from './types' + +const profile: HostProfile = { + id: 'host-ready', + name: 'Ready', + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key-ready', + lastConnected: 0 +} + +function unavailable( + id: string, + credentialStatus: 'temporarily-unavailable' | 'missing' +): HostCatalogEntry { + return { + id, + name: id, + endpoint: `ws://127.0.0.1/${id}`, + publicKeyB64: `key-${id}`, + lastConnected: 0, + credentialStatus, + profile: null + } +} + +describe('selectConnectableHostProfiles', () => { + it('excludes unavailable catalog entries from connection priming', () => { + expect( + selectConnectableHostProfiles([ + { ...profile, credentialStatus: 'ready', profile }, + unavailable('host-locked', 'temporarily-unavailable'), + unavailable('host-missing', 'missing') + ]) + ).toEqual([profile]) + }) +}) diff --git a/mobile/src/transport/host-catalog-selection.ts b/mobile/src/transport/host-catalog-selection.ts new file mode 100644 index 00000000000..31477dd7f6d --- /dev/null +++ b/mobile/src/transport/host-catalog-selection.ts @@ -0,0 +1,7 @@ +import type { HostCatalogEntry, HostProfile } from './types' + +export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry[]): HostProfile[] { + return catalog.flatMap((entry) => + entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : [] + ) +} diff --git a/mobile/src/transport/host-client-acquisition-count.ts b/mobile/src/transport/host-client-acquisition-count.ts new file mode 100644 index 00000000000..1a1123e5c8b --- /dev/null +++ b/mobile/src/transport/host-client-acquisition-count.ts @@ -0,0 +1,9 @@ +export function decrementPendingAcquisition(pending: Map, hostId: string): number { + const next = Math.max(0, (pending.get(hostId) ?? 0) - 1) + if (next === 0) { + pending.delete(hostId) + } else { + pending.set(hostId, next) + } + return next +} diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts new file mode 100644 index 00000000000..e0f7243baa8 --- /dev/null +++ b/mobile/src/transport/host-client-context-state.ts @@ -0,0 +1,72 @@ +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile } from './types' + +export type CloseEntryOptions = { + forgetPrimedHost: boolean + preserveAcquisitions: boolean +} + +export function notifyHostStateListeners( + listeners: Map void>>, + hostId: string, + state: ConnectionState +): void { + for (const listener of listeners.get(hostId) ?? []) { + listener(state) + } +} + +export function notifyAllHostListeners(listeners: Set<() => void>): void { + for (const listener of listeners) { + listener() + } +} + +export function subscribeHostStateListener( + listeners: Map void>>, + hostId: string, + listener: (state: ConnectionState) => void +): () => void { + let hostListeners = listeners.get(hostId) + if (!hostListeners) { + hostListeners = new Set() + listeners.set(hostId, hostListeners) + } + hostListeners.add(listener) + return () => { + hostListeners.delete(listener) + if (hostListeners.size === 0) { + listeners.delete(hostId) + } + } +} + +export function subscribeAllHostListener( + listeners: Set<() => void>, + listener: () => void +): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function listHostClients( + entries: ReadonlyMap +): { hostId: string; client: RpcClient }[] { + return [...entries].map(([hostId, entry]) => ({ hostId, client: entry.client })) +} + +export function primeHostProfiles(cache: Map, hosts: HostProfile[]): void { + for (const host of hosts) { + cache.set(host.id, host) + } +} + +export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { + const logical = client as Partial | undefined + if (typeof logical?.getActivePath !== 'function') { + return 'lan' + } + // Why: during migration the pending path is what the user is waiting on. + return logical.getPendingPath?.() ?? logical.getActivePath() +} diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts index bd483a3280f..146a845a96b 100644 --- a/mobile/src/transport/host-credential-cleanup.test.ts +++ b/mobile/src/transport/host-credential-cleanup.test.ts @@ -1,8 +1,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + cancelPendingHostCredentialCleanup, loadPendingHostCredentialCleanup, loadPendingHostCredentialCleanupIds, + recordHostCredentialCleanupIntent, resetHostCredentialCleanupForTests, retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup, @@ -66,6 +68,12 @@ describe('host credential cleanup', () => { expect(deleteCredential).toHaveBeenCalledOnce() }) + it('records cleanup intent without starting native deletion', async () => { + await recordHostCredentialCleanupIntent('host-1') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + }) + it('persists cleanup intent before a rejected delete for manual retry', async () => { const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable')) const listener = vi.fn() @@ -78,6 +86,18 @@ describe('host credential cleanup', () => { unsubscribe() }) + it('cancels stale cleanup intent after replacement publication', async () => { + const deleteCredential = vi.fn().mockRejectedValue(new Error('superseded')) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 20) + await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce()) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + + await cancelPendingHostCredentialCleanup('host-1') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + it('records intent before a stalled delete times out', async () => { vi.useFakeTimers() const deleteCredential = vi.fn(() => new Promise(() => undefined)) diff --git a/mobile/src/transport/host-credential-cleanup.ts b/mobile/src/transport/host-credential-cleanup.ts index d64e37a98dc..96a2623b514 100644 --- a/mobile/src/transport/host-credential-cleanup.ts +++ b/mobile/src/transport/host-credential-cleanup.ts @@ -155,13 +155,17 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia async function recordCleanupIntent(hostId: string): Promise { try { - await addPendingId(hostId) + await recordHostCredentialCleanupIntent(hostId) return true } catch { return false } } +export async function recordHostCredentialCleanupIntent(hostId: string): Promise { + await addPendingId(hostId) +} + async function confirmNativeCleanup( hostId: string, deleteCredential: DeleteHostCredential, @@ -209,6 +213,11 @@ export function subscribePendingHostCredentialCleanup(listener: () => void): () return () => pendingListeners.delete(listener) } +export async function cancelPendingHostCredentialCleanup(hostId: string): Promise { + clearUnrecordedPending(hostId) + await removePendingId(hostId) +} + /** * Record cleanup intent, then fire-and-forget the native keychain delete so * removeHost never blocks on SecureStore. If the durable intent write fails, diff --git a/mobile/src/transport/host-credential-write-revision.ts b/mobile/src/transport/host-credential-write-revision.ts new file mode 100644 index 00000000000..12207aa6a12 --- /dev/null +++ b/mobile/src/transport/host-credential-write-revision.ts @@ -0,0 +1,21 @@ +let nextRevision = 1 +const revisionByHostId = new Map() + +export function getHostCredentialWriteRevision(hostId: string): number { + return revisionByHostId.get(hostId) ?? 0 +} + +export function markHostCredentialWrite(hostId: string): void { + revisionByHostId.set(hostId, nextRevision) + nextRevision += 1 +} + +export function clearHostCredentialWriteRevision(hostId: string): void { + revisionByHostId.delete(hostId) +} + +/** Test-only: clear session credential generations between cases. */ +export function resetHostCredentialWriteRevisionsForTests(): void { + nextRevision = 1 + revisionByHostId.clear() +} diff --git a/mobile/src/transport/host-device-token-store.ts b/mobile/src/transport/host-device-token-store.ts new file mode 100644 index 00000000000..297177e0dbe --- /dev/null +++ b/mobile/src/transport/host-device-token-store.ts @@ -0,0 +1,43 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { Platform } from 'react-native' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' + +// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. +const TOKEN_KEY_PREFIX = 'orca.host-token.' +const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' + +function tokenKey(hostId: string): string { + return `${TOKEN_KEY_PREFIX}${hostId}` +} + +function webTokenKey(hostId: string): string { + return `${WEB_TOKEN_KEY_PREFIX}${hostId}` +} + +export async function readHostDeviceToken(hostId: string): Promise { + // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. + if (Platform.OS === 'web') { + return AsyncStorage.getItem(webTokenKey(hostId)) + } + return readPairingKeychainItem(tokenKey(hostId)) +} + +export async function writeHostDeviceToken(hostId: string, token: string): Promise { + if (Platform.OS === 'web') { + await AsyncStorage.setItem(webTokenKey(hostId), token) + return + } + await writePairingKeychainItem(tokenKey(hostId), token) +} + +export async function deleteHostDeviceToken(hostId: string): Promise { + if (Platform.OS === 'web') { + await AsyncStorage.removeItem(webTokenKey(hostId)) + return + } + await deletePairingKeychainItem(tokenKey(hostId)) +} diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts index cd0de461288..db463413a46 100644 --- a/mobile/src/transport/host-edit-navigation.test.ts +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -52,7 +52,7 @@ describe('mobile host edit navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToMobileHostEdit(harness.navigation, { push }, 'host/1') + navigateToMobileHostEdit(harness.navigation, { push, replace: vi.fn() }, 'host/1') expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -75,7 +75,7 @@ describe('mobile host edit navigation', () => { it('does not replace an unrelated host route', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') harness.setState(committedHostState('host-2')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -83,7 +83,7 @@ describe('mobile host edit navigation', () => { it('disposes itself when navigation leaves the host flow without cancel()', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') // Enter the host flow for a different host, then leave it entirely. harness.setState(committedHostState('host-2')) @@ -97,7 +97,11 @@ describe('mobile host edit navigation', () => { it('cancels a pending replacement when navigation leaves the host flow', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + const controller = navigateToMobileHostEdit( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1' + ) harness.setState(committedHostState('host-2')) controller.cancel() diff --git a/mobile/src/transport/host-endpoint-label.ts b/mobile/src/transport/host-endpoint-label.ts new file mode 100644 index 00000000000..e326a650af2 --- /dev/null +++ b/mobile/src/transport/host-endpoint-label.ts @@ -0,0 +1,11 @@ +export function hostEndpointLabel(endpoint: string): string { + try { + const url = new URL(endpoint) + if (!url.hostname) { + return 'Unknown endpoint' + } + return `${url.hostname}${url.port ? `:${url.port}` : ''}` + } catch { + return 'Unknown endpoint' + } +} diff --git a/mobile/src/transport/host-list-load-sharing.ts b/mobile/src/transport/host-list-load-sharing.ts index ab1e9e2fe89..15995e3ba25 100644 --- a/mobile/src/transport/host-list-load-sharing.ts +++ b/mobile/src/transport/host-list-load-sharing.ts @@ -1,15 +1,22 @@ -import type { HostProfile } from './types' +import type { HostCatalogEntry, HostProfile } from './types' + +export type HostListSnapshot = { + catalog: HostCatalogEntry[] + profiles: HostProfile[] +} // Why: concurrent callers share a slow Keychain pass; durable writes invalidate // it so later loads cannot receive stale snapshots (#8791). -let inflight: Promise | null = null +let inflight: Promise | null = null let revision = 0 export function getHostListLoadRevision(): number { return revision } -export function shareHostListLoad(load: () => Promise): Promise { +export function shareHostListLoad( + load: () => Promise +): Promise { if (inflight) { return inflight } diff --git a/mobile/src/transport/host-metadata-store.ts b/mobile/src/transport/host-metadata-store.ts new file mode 100644 index 00000000000..7962f43dc37 --- /dev/null +++ b/mobile/src/transport/host-metadata-store.ts @@ -0,0 +1,51 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { StoredHostProfileSchema, type HostProfile, type StoredHostProfile } from './types' + +const STORAGE_KEY = 'orca:hosts' + +export async function loadStoredHostProfiles(): Promise { + return parseStoredHostProfiles(await AsyncStorage.getItem(STORAGE_KEY)) +} + +export async function readStoredHostProfilesForMutation(): Promise { + try { + const parsed = await loadStoredHostProfiles() + if (parsed) { + return parsed + } + } catch { + // Normalize storage and payload failures for fail-closed mutations. + } + throw new Error('host list storage unreadable') +} + +export function writeStoredHostProfiles(hosts: readonly StoredHostProfile[]): Promise { + return AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) +} + +export function toStoredHostProfile(host: HostProfile): StoredHostProfile { + const { id, name, endpoint, publicKeyB64, lastConnected } = host + return { id, name, endpoint, publicKeyB64, lastConnected } +} + +function parseStoredHostProfiles(raw: string | null): StoredHostProfile[] | null { + if (!raw) { + return [] + } + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + return null + } + return parsed.flatMap((item) => { + // Why: pre-v0.0.3 records embedded secrets; users re-pair instead of migrating them. + if (item && typeof item === 'object' && 'deviceToken' in item) { + return [] + } + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null + } +} diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 997e8a3d42f..ddb8f92a92b 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -14,6 +14,8 @@ const secureStoreMock = vi.hoisted(() => ({ })) const scheduleCleanupMock = vi.hoisted(() => vi.fn()) +const cancelCleanupMock = vi.hoisted(() => vi.fn()) +const recordCleanupIntentMock = vi.hoisted(() => vi.fn()) const platformMock = vi.hoisted(() => ({ OS: 'ios' })) vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -30,11 +32,14 @@ vi.mock('react-native', () => ({ })) vi.mock('./host-credential-cleanup', () => ({ + cancelPendingHostCredentialCleanup: (...args: unknown[]) => cancelCleanupMock(...args), + recordHostCredentialCleanupIntent: (...args: unknown[]) => recordCleanupIntentMock(...args), scheduleHostCredentialCleanup: (...args: unknown[]) => scheduleCleanupMock(...args), retryPendingHostCredentialCleanups: vi.fn() })) import { + loadHostCatalog, loadHosts, MobileRelayUpgradeHostRemovedError, removeHost, @@ -46,6 +51,7 @@ import { updateLastConnected } from './host-store' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' +import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' const HOSTS_STORAGE_KEY = 'orca:hosts' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' @@ -63,6 +69,23 @@ const HOST_TWO = { publicKeyB64: 'key-2', lastConnected: 0 } +const HOST_ONE_RELAY_BUNDLE = { + v: 1 as const, + hostId: HOST_ONE.id, + deviceToken: 'replacement-token', + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 1, + expiresAt: 10_000 + } +} + +function scheduledCleanup(hostId: string): (id: string) => Promise { + const cleanup = scheduleCleanupMock.mock.calls.find(([id]) => id === hostId)?.[1] + expect(cleanup).toBeTypeOf('function') + return cleanup as (id: string) => Promise +} describe('host-store list mutations', () => { let storedHostsRaw: string @@ -75,6 +98,10 @@ describe('host-store list mutations', () => { resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) + cancelCleanupMock.mockReset() + cancelCleanupMock.mockResolvedValue(undefined) + recordCleanupIntentMock.mockReset() + recordCleanupIntentMock.mockResolvedValue(undefined) storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO]) storedOverlayRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { @@ -93,6 +120,7 @@ describe('host-store list mutations', () => { storedOverlayRaw = raw } }) + secureStoreMock.setItemAsync.mockResolvedValue(undefined) secureStoreMock.getItemAsync.mockImplementation(async (key: string) => key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null ) @@ -114,6 +142,55 @@ describe('host-store list mutations', () => { expect(asyncStorageMock.getItem).toHaveBeenCalledOnce() }) + it('keeps a host visible when its credential read temporarily fails', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => { + if (key.endsWith(HOST_ONE.id)) { + throw new Error('keychain locked') + } + return key.endsWith(HOST_TWO.id) ? 'token-2' : null + }) + + const catalog = await loadHostCatalog() + const profiles = await loadHosts() + + expect(catalog.map(({ id }) => id)).toEqual([HOST_ONE.id, HOST_TWO.id]) + expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + credentialStatus: 'temporarily-unavailable', + profile: null + }) + expect(profiles.map(({ id }) => id)).toEqual([HOST_TWO.id]) + }) + + it('keeps orphaned metadata visible when its credential is missing', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => + key.endsWith(HOST_TWO.id) ? 'token-2' : null + ) + + const catalog = await loadHostCatalog() + + expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + credentialStatus: 'missing', + profile: null + }) + }) + + it('keeps existing metadata when pairing a distinct host', async () => { + await saveHost({ + id: 'host-new', + name: 'Host 3', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'token-new', + lastConnected: 0 + }) + + expect(JSON.parse(storedHostsRaw).map(({ id }: { id: string }) => id)).toEqual([ + HOST_ONE.id, + HOST_TWO.id, + 'host-new' + ]) + }) + it('fails closed when durable host identity storage is unreadable', async () => { asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable')) @@ -134,9 +211,173 @@ describe('host-store list mutations', () => { }) expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }]) + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + asyncStorageMock.setItem.mock.invocationCallOrder[0]! + ) expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function)) }) + it('does not remove same-key metadata when the replacement credential write fails', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + secureStoreMock.setItemAsync.mockRejectedValue(new Error('keychain write failed')) + + await expect( + saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + ).rejects.toThrow('keychain write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) + expect(cancelCleanupMock).toHaveBeenCalledWith(duplicate.id) + expect(scheduleCleanupMock).not.toHaveBeenCalled() + }) + + it('serves a committed replacement token when same-key metadata persistence fails', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + await loadHosts() + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect( + saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + ).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) + await expect( + loadHosts().then((hosts) => hosts.find(({ id }) => id === HOST_ONE.id)?.deviceToken) + ).resolves.toBe('replacement-token') + }) + + it('records cleanup when new same-key metadata fails after its credential commits', async () => { + const replacement = { + ...HOST_TWO, + id: 'host-replacement', + publicKeyB64: HOST_ONE.publicKeyB64, + deviceToken: 'replacement-token' + } + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect(saveHost(replacement)).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(recordCleanupIntentMock).toHaveBeenCalledWith(replacement.id) + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + secureStoreMock.setItemAsync.mock.invocationCallOrder[0]! + ) + expect(scheduleCleanupMock).toHaveBeenCalledWith(replacement.id, expect.any(Function)) + }) + + it('does not clean a duplicate id that a later same-key save made authoritative', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + await saveHost({ ...HOST_ONE, deviceToken: 'token-a' }) + const staleCleanup = scheduledCleanup(duplicate.id) + + await saveHost({ ...duplicate, deviceToken: 'token-b' }) + await staleCleanup(duplicate.id) + + expect(JSON.parse(storedHostsRaw)).toEqual([duplicate]) + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('does not let stale removal cleanup delete a replacement relay bundle before publication', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + await writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + + await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('keeps stale cleanup pending when a replacement relay write fails', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + secureStoreMock.setItemAsync.mockRejectedValueOnce(new Error('keychain write failed')) + + await expect(writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)).rejects.toThrow( + 'keychain write failed' + ) + await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('stops stale cleanup when a replacement relay write starts during token deletion', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + let releaseTokenDelete: () => void = () => {} + secureStoreMock.deleteItemAsync.mockReturnValueOnce( + new Promise((resolve) => { + releaseTokenDelete = resolve + }) + ) + + const cleanup = staleCleanup(HOST_ONE.id) + await vi.waitFor(() => { + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-1', + expect.anything() + ) + }) + const bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + releaseTokenDelete() + await bundleWrite + await expect(cleanup).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalledWith( + 'orca.mobile-relay.credentials.host-1', + expect.anything() + ) + }) + + it('rechecks the write generation immediately before starting credential deletion', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + let resolveCleanupRead: (raw: string) => void = () => {} + const cleanupRead = new Promise((resolve) => { + resolveCleanupRead = resolve + }) + let cleanupReadStarted = false + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY && !cleanupReadStarted) { + cleanupReadStarted = true + return cleanupRead + } + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + if (key === OVERLAY_STORAGE_KEY) { + return storedOverlayRaw + } + return null + }) + + const cleanup = staleCleanup(HOST_ONE.id) + await vi.waitFor(() => expect(cleanupReadStarted).toBe(true)) + let bundleWrite: Promise | null = null + resolveCleanupRead(storedHostsRaw) + queueMicrotask(() => { + bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + }) + await expect(cleanup).rejects.toThrow('credential write superseded cleanup') + await vi.waitFor(() => expect(bundleWrite).not.toBeNull()) + await bundleWrite + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + it('clears stale relay state when an existing host is re-paired direct-only', async () => { const overlay: MobileRelayHostOverlay = { v: 2, @@ -209,7 +450,63 @@ describe('host-store list mutations', () => { expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) }) - it('merges v2 endpoints only onto an existing legacy base host', async () => { + it('cancels write-ahead cleanup when host metadata removal fails', async () => { + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect(removeHost(HOST_ONE.id)).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(cancelCleanupMock).toHaveBeenCalledWith(HOST_ONE.id) + }) + + it('does not cancel cleanup from a removal committed during a stalled save', async () => { + let releaseTokenWrite: () => void = () => {} + secureStoreMock.setItemAsync.mockReturnValueOnce( + new Promise((resolve) => { + releaseTokenWrite = resolve + }) + ) + + const save = saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledOnce()) + await removeHost(HOST_ONE.id) + releaseTokenWrite() + await save + await loadHostCatalog() + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) + expect(cancelCleanupMock).not.toHaveBeenCalled() + }) + + it('refuses an early same-key token write without durable cleanup intent', async () => { + const replacement = { + ...HOST_TWO, + id: 'host-replacement', + publicKeyB64: HOST_ONE.publicKeyB64, + deviceToken: 'replacement-token' + } + recordCleanupIntentMock.mockRejectedValueOnce(new Error('intent storage unavailable')) + + await expect(saveHost(replacement)).rejects.toThrow('intent storage unavailable') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + + it('deletes an unpaired credential when cleanup runs without a newer write', async () => { + await removeHost(HOST_ONE.id) + const cleanup = scheduledCleanup(HOST_ONE.id) + + await cleanup(HOST_ONE.id) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-1', + expect.anything() + ) + }) + + it('keeps a concurrently re-published orphan overlay authoritative', async () => { const overlay: MobileRelayHostOverlay = { v: 2, hostId: HOST_ONE.id, @@ -232,8 +529,26 @@ describe('host-store list mutations', () => { } } storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }]) + let releaseCleanup: () => void = () => {} + scheduleCleanupMock.mockReturnValueOnce( + new Promise((resolve) => { + releaseCleanup = resolve + }) + ) - const hosts = await loadHosts() + const hostsLoad = loadHosts() + await vi.waitFor(() => expect(scheduleCleanupMock).toHaveBeenCalled()) + await saveHost({ + ...HOST_ONE, + id: 'removed-by-old-build', + publicKeyB64: 'restored-key', + deviceToken: 'restored-token', + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + }) + releaseCleanup() + const hosts = await hostsLoad expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ endpoints: overlay.endpoints, @@ -241,6 +556,10 @@ describe('host-store list mutations', () => { relay: overlay.relay }) expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) + expect(JSON.parse(storedOverlayRaw!)).toContainEqual({ + ...overlay, + hostId: 'removed-by-old-build' + }) }) it('refuses to resurrect a removed host during relay upgrade publication', async () => { @@ -270,7 +589,9 @@ describe('host-store list mutations', () => { expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) }) expect(settled).toBe(false) - expect(scheduleCleanupMock).toHaveBeenCalledOnce() + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + asyncStorageMock.setItem.mock.invocationCallOrder[0]! + ) resolveSchedule?.() await removal @@ -522,6 +843,10 @@ describe('host-store pairing save after an Android encryption rejection', () => resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) + cancelCleanupMock.mockReset() + cancelCleanupMock.mockResolvedValue(undefined) + recordCleanupIntentMock.mockReset() + recordCleanupIntentMock.mockResolvedValue(undefined) storedHostsRaw = '[]' storedGenerationRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 43657fffe76..99421ffd192 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,20 +1,13 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { Platform } from 'react-native' -import { - HostProfileSchema, - StoredHostProfileSchema, - type HostProfile, - type StoredHostProfile -} from './types' +import { HostProfileSchema } from './types' +import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' import * as hostListLoads from './host-list-load-sharing' +import { joinHostCatalogCredentials } from './host-catalog-credential-join' +import { resetPairingKeychainForTests } from './pairing-keychain' +import { readHostDeviceToken, writeHostDeviceToken } from './host-device-token-store' import { - deletePairingKeychainItem, - readPairingKeychainItem, - resetPairingKeychainForTests, - writePairingKeychainItem -} from './pairing-keychain' -import { + cancelPendingHostCredentialCleanup, + recordHostCredentialCleanupIntent, retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup } from './host-credential-cleanup' @@ -24,137 +17,67 @@ import { removeMobileRelayHostOverlays, saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' -import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' -import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' +import { + getHostCredentialWriteRevision, + markHostCredentialWrite, + resetHostCredentialWriteRevisionsForTests +} from './host-credential-write-revision' +import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion' +import { + loadStoredHostProfiles, + readStoredHostProfilesForMutation, + toStoredHostProfile, + writeStoredHostProfiles +} from './host-metadata-store' -const STORAGE_KEY = 'orca:hosts' -// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. -const TOKEN_KEY_PREFIX = 'orca.host-token.' -const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' - -function tokenKey(hostId: string): string { - return `${TOKEN_KEY_PREFIX}${hostId}` -} - -function webTokenKey(hostId: string): string { - return `${WEB_TOKEN_KEY_PREFIX}${hostId}` -} - -async function readDeviceToken(hostId: string): Promise { - // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. - if (Platform.OS === 'web') { - return AsyncStorage.getItem(webTokenKey(hostId)) - } - return readPairingKeychainItem(tokenKey(hostId)) -} - -async function writeDeviceToken(hostId: string, token: string): Promise { - if (Platform.OS === 'web') { - await AsyncStorage.setItem(webTokenKey(hostId), token) - return - } - await writePairingKeychainItem(tokenKey(hostId), token) -} - -async function deleteDeviceToken(hostId: string): Promise { - if (Platform.OS === 'web') { - await AsyncStorage.removeItem(webTokenKey(hostId)) - return - } - await deletePairingKeychainItem(tokenKey(hostId)) -} - -async function deleteHostCredentials(hostId: string): Promise { - await deleteDeviceToken(hostId) - await deleteMobileRelayCredentialBundle(hostId) - await deleteMobileRelayDirectUpgradeJournal(hostId) +async function commitDeviceToken(hostId: string, token: string): Promise { + markHostCredentialWrite(hostId) + await writeHostDeviceToken(hostId, token) + tokenCache.set(hostId, token) + hostListLoads.dropSharedHostListLoad() } // Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove. const tokenCache = new Map() -// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename). +// Why: serialize host metadata RMW so concurrent writers cannot drop updates. let hostListMutation: Promise = Promise.resolve() -function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { - if (!raw) { - return [] - } - try { - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) { - return null - } - return parsed.flatMap((item) => { - // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim. - if (item && typeof item === 'object' && 'deviceToken' in item) { - return [] - } - const result = StoredHostProfileSchema.safeParse(item) - return result.success ? [result.data] : [] - }) - } catch { - return null - } -} +export const loadHosts = async (): Promise => (await loadHostListSnapshot()).profiles +export const loadHostCatalog = async (): Promise => + (await loadHostListSnapshot()).catalog -export async function loadHosts(): Promise { +async function loadHostListSnapshot(): Promise { // Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list. await hostListMutation // Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass. - return hostListLoads.shareHostListLoad(doLoadHosts) + return hostListLoads.shareHostListLoad(doLoadHostListSnapshot) } -async function doLoadHosts(): Promise { - const raw = await AsyncStorage.getItem(STORAGE_KEY) - const storedHosts = parseStoredHosts(raw) +async function doLoadHostListSnapshot(): Promise { + const storedHosts = await loadStoredHostProfiles() if (!storedHosts) { - return [] + return { catalog: [], profiles: [] } } const overlayState = await loadMobileRelayHostOverlayState( new Set(storedHosts.map(({ id }) => id)) ) + const orphanWriteRevisions = new Map( + overlayState.orphanHostIds.map((hostId) => [hostId, getHostCredentialWriteRevision(hostId)]) + ) await scheduleOrphanedMobileRelayCleanup({ hostIds: overlayState.orphanHostIds, - deleteCredential: deleteHostCredentials + deleteCredential: (hostId) => + deleteUnpairedHostCredentials(hostId, orphanWriteRevisions.get(hostId) ?? 0), + removeOverlay: removeOrphanOverlayIfUnpaired + }) + return joinHostCatalogCredentials({ + storedHosts, + overlays: overlayState.overlays, + tokenCache, + readToken: readHostDeviceToken, + getRevision: hostListLoads.getHostListLoadRevision }) - const overlays = overlayState.overlays - - const out: HostProfile[] = [] - for (const stored of storedHosts) { - let token = tokenCache.get(stored.id) - if (!token) { - const readRevision = hostListLoads.getHostListLoadRevision() - let fetched: string | null - try { - fetched = await readDeviceToken(stored.id) - } catch { - // Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it. - continue - } - if (!fetched) { - // Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host. - continue - } - token = fetched - if (readRevision === hostListLoads.getHostListLoadRevision()) { - tokenCache.set(stored.id, token) - } - } - const overlay = overlays.get(stored.id) - out.push({ - ...stored, - deviceToken: token, - ...(overlay - ? { - endpoints: overlay.endpoints, - relayHostId: overlay.relayHostId, - relay: overlay.relay - } - : {}) - }) - } - return out } export async function resolvePairingHostIdentity( @@ -163,91 +86,146 @@ export async function resolvePairingHostIdentity( ): Promise<{ id: string; name: string }> { // Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards. await hostListMutation - const hosts = await readStoredHostsForMutation() + const hosts = await readStoredHostProfilesForMutation() const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64) return match ? { id: match.id, name: match.name } : { id: newHostId, name: getNextHostNameFromHosts(hosts) } } -async function readStoredHostsForMutation(): Promise { - try { - const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) - if (!parsed) { - // Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write. - throw new Error('host list storage unreadable') - } - return parsed - } catch (error) { - if (error instanceof Error && error.message === 'host list storage unreadable') { - throw error - } - throw new Error('host list storage unreadable') +const deleteUnpairedHostCredentials = createUnpairedHostCredentialDeletion({ + waitForHostMutations: () => hostListMutation, + hasStoredHost: async (hostId) => + (await readStoredHostProfilesForMutation()).some(({ id }) => id === hostId), + onDeleted: (hostId) => { + tokenCache.delete(hostId) + hostListLoads.dropSharedHostListLoad() } +}) + +function scheduleUnpairedHostCredentialCleanup(hostId: string): Promise { + const writeRevision = getHostCredentialWriteRevision(hostId) + return scheduleHostCredentialCleanup(hostId, (id) => + deleteUnpairedHostCredentials(id, writeRevision) + ) } -async function mutateStoredHosts( - update: (hosts: StoredHostProfile[]) => StoredHostProfile[] -): Promise { - const mutation = hostListMutation.then(async () => { - const current = await readStoredHostsForMutation() - const next = update(current) - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)) - hostListLoads.dropSharedHostListLoad() +function cancelCleanupForStoredHost(hostId: string): void { + const cancellation = hostListMutation.then(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (hosts.some(({ id }) => id === hostId)) { + // Register before later removals enqueue their intent, without blocking host loads on cleanup storage. + void cancelPendingHostCredentialCleanup(hostId).catch(() => undefined) + } }) + hostListMutation = cancellation.catch(() => {}) +} + +async function cancelCleanupForDurablyStoredHosts(hostIds: Iterable): Promise { + const targets = [...hostIds] + return enqueueHostListMutation(async () => { + const storedIds = new Set((await readStoredHostProfilesForMutation()).map(({ id }) => id)) + await Promise.all( + targets + .filter((hostId) => storedIds.has(hostId)) + .map((hostId) => cancelPendingHostCredentialCleanup(hostId).catch(() => undefined)) + ) + }).catch(() => undefined) +} + +function enqueueHostListMutation(operation: () => Promise): Promise { + const mutation = hostListMutation.then(operation) hostListMutation = mutation.catch(() => {}) return mutation } -function toStored(host: HostProfile): StoredHostProfile { - return { - id: host.id, - name: host.name, - endpoint: host.endpoint, - publicKeyB64: host.publicKeyB64, - lastConnected: host.lastConnected - } +function removeOrphanOverlayIfUnpaired(hostId: string): Promise { + return enqueueHostListMutation(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (!hosts.some(({ id }) => id === hostId)) { + await removeMobileRelayHostOverlay(hostId) + } + }) +} + +async function mutateStoredHosts( + update: (hosts: StoredHostProfile[]) => StoredHostProfile[] | Promise +): Promise { + return enqueueHostListMutation(async () => { + const current = await readStoredHostProfilesForMutation() + const next = await update(current) + await writeStoredHostProfiles(next) + hostListLoads.dropSharedHostListLoad() + }) } export class MobileRelayUpgradeHostRemovedError extends Error {} -export async function saveHost(host: HostProfile): Promise { - await persistHost(host, false) -} +export const saveHost = (host: HostProfile): Promise => persistHost(host, false) -export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise { - await persistHost(host, true) -} +export const saveExistingHostRelayUpgrade = (host: HostProfile): Promise => + persistHost(host, true) async function persistHost(host: HostProfile, requireExisting: boolean): Promise { const validated = HostProfileSchema.parse(host) - const stored = toStored(validated) + const stored = toStoredHostProfile(validated) const duplicateHostIds = new Set() let updatedExistingHost = false - await mutateStoredHosts((hosts) => { - const index = hosts.findIndex((h) => h.id === stored.id) - for (const candidate of hosts) { - if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { - duplicateHostIds.add(candidate.id) + let cleanupIntentRecordedBeforeMetadata = false + let tokenCommittedBeforeMetadata = false + try { + await mutateStoredHosts(async (hosts) => { + const index = hosts.findIndex((h) => h.id === stored.id) + for (const candidate of hosts) { + if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { + duplicateHostIds.add(candidate.id) + } + } + let next: StoredHostProfile[] + if (index >= 0) { + updatedExistingHost = true + // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. + next = hosts + .filter(({ id }) => !duplicateHostIds.has(id)) + .map((candidate) => (candidate.id === stored.id ? stored : candidate)) + } else if (requireExisting) { + // Why: an in-flight relay upgrade must not resurrect a host the user removed. + throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + } else { + next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] + } + if (duplicateHostIds.size > 0) { + if (index < 0) { + // Why: process death between the early token write and metadata publication must leave cleanup discoverable. + await recordHostCredentialCleanupIntent(stored.id) + cleanupIntentRecordedBeforeMetadata = true + } + for (const duplicateHostId of duplicateHostIds) { + await recordHostCredentialCleanupIntent(duplicateHostId) + } + // Why: never remove the only usable same-key row until its replacement credential is durable. + await commitDeviceToken(stored.id, validated.deviceToken) + tokenCommittedBeforeMetadata = true + } + return next + }) + } catch (error) { + await cancelCleanupForDurablyStoredHosts(duplicateHostIds) + if (cleanupIntentRecordedBeforeMetadata) { + try { + await scheduleUnpairedHostCredentialCleanup(stored.id) + } catch { + // The write-ahead cleanup intent remains available for retry. } } - if (index >= 0) { - updatedExistingHost = true - // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. - return hosts - .filter(({ id }) => !duplicateHostIds.has(id)) - .map((candidate) => (candidate.id === stored.id ? stored : candidate)) - } - if (requireExisting) { - // Why: an in-flight relay upgrade must not resurrect a host the user removed. - throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') - } - return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] - }) - // Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever. - await writeDeviceToken(stored.id, validated.deviceToken) - tokenCache.set(stored.id, validated.deviceToken) - hostListLoads.dropSharedHostListLoad() + throw error + } + if (!tokenCommittedBeforeMetadata) { + // Why: the catalog can now surface a failed token write for recovery instead of losing the host. + await commitDeviceToken(stored.id, validated.deviceToken) + } + // Why: a later removal owns its cleanup intent; cancel only while this publication remains authoritative. + cancelCleanupForStoredHost(stored.id) if (validated.endpoints) { await saveMobileRelayHostOverlay({ v: 2, @@ -268,9 +246,8 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise hostListLoads.dropSharedHostListLoad() } for (const duplicateHostId of duplicateHostIds) { - tokenCache.delete(duplicateHostId) try { - await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials) + await scheduleUnpairedHostCredentialCleanup(duplicateHostId) } catch { // Metadata is already deduplicated; orphan-token recovery is best-effort. } @@ -278,7 +255,23 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } export async function removeHost(hostId: string): Promise { - await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId)) + let cleanupIntentRecorded = false + try { + await mutateStoredHosts(async (hosts) => { + try { + await recordHostCredentialCleanupIntent(hostId) + cleanupIntentRecorded = true + } catch { + // Removal remains authoritative when cleanup intent storage is unavailable. + } + return hosts.filter((h) => h.id !== hostId) + }) + } catch (error) { + if (cleanupIntentRecorded) { + await cancelCleanupForDurablyStoredHosts([hostId]) + } + throw error + } tokenCache.delete(hostId) try { await removeMobileRelayHostOverlay(hostId) @@ -288,7 +281,7 @@ export async function removeHost(hostId: string): Promise { } // Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI. try { - await scheduleHostCredentialCleanup(hostId, deleteHostCredentials) + await scheduleUnpairedHostCredentialCleanup(hostId) } catch { // Metadata is already committed; orphan-token recovery is best-effort. } @@ -299,7 +292,9 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ remainingIds: string[] storageUnreadable: boolean }> { - return retryPendingHostCredentialCleanups(deleteHostCredentials) + return retryPendingHostCredentialCleanups((hostId) => + deleteUnpairedHostCredentials(hostId, getHostCredentialWriteRevision(hostId)) + ) } // Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other. @@ -342,6 +337,7 @@ export async function updateLastConnected(hostId: string): Promise { export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() + resetHostCredentialWriteRevisionsForTests() hostListLoads.dropSharedHostListLoad() resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index a08fa052459..089c6d28c56 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -7,6 +7,7 @@ import { readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' +import { markHostCredentialWrite } from './host-credential-write-revision' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) const ResumeCredentialSchema = z @@ -91,6 +92,7 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) + markHostCredentialWrite(validated.hostId) await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated)) } diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 2493c9e78af..e21e75a6b61 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -6,6 +6,7 @@ import { readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' +import { markHostCredentialWrite } from './host-credential-write-revision' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -60,6 +61,7 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) + markHostCredentialWrite(parsed.hostId) await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed)) } diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index 41d53189880..96d71b37680 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => { expect(saved.metadata.authorizationMode).toBe('authenticated-direct') expect(deps.updateJournal).toHaveBeenCalledTimes(2) }) + // Why: a journal stranded by a relay outage used to block every later pairing + // with "recovery pending" forever, because recovery only ever deferred. + it('abandons a journal once its invite expired and no credential can reconcile', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned') + expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) + }) + + it('keeps a just-expired journal so a brief outage cannot discard it', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + // Why: a committed install whose local persistence failed is the one case the + // journal must survive — it is the only record left to retry the write from. + it('keeps a journal when the server committed but the local write failed', async () => { + const saved = journal() + const directInstalled = installed(saved, 'authenticated-direct') + const committed = client(async () => + response(endpoints(saved, { state: 'committed', result: directInstalled })) + ) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1, + writeCredentialBundle: vi.fn(async () => { + throw new Error('keychain unavailable') + }) + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index 0d5ba7e5e07..26dc08620de 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -27,7 +27,7 @@ import { import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import type { HostProfile, RpcResponse } from './types' -export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' +export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned' type RecoveryDependencies = { loadJournal: typeof loadMobileRelayPairingJournal @@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = { platform: Platform.OS } +// One full invite lifetime past expiry, so a momentary outage never discards a +// journal that a later launch could still reconcile. +const ABANDON_GRACE_MS = 10 * 60 * 1000 + let recoveryPromise: Promise | null = null export function recoverMobileRelayPairing( @@ -96,6 +100,10 @@ async function runRecovery( } const credentials = recoveryCredentials(journal, bundle, dependencies.now()) + // Why: publishCommitted runs inside the catch below, so a failed local write + // of an authoritatively committed install must not look like "nothing to + // reconcile" — that journal is the only record left to retry the write from. + let observedCommitted = false for (const credential of credentials) { let client: PairingCandidateClient | null = null try { @@ -113,6 +121,7 @@ async function runRecovery( }) const endpoints = await getRecoveryStatus(client, journal, credential.kind) if (endpoints.installStatus?.state === 'committed') { + observedCommitted = true await publishCommitted(journal, endpoints, dependencies) return 'recovered' } @@ -128,6 +137,7 @@ async function runRecovery( ) const reconciled = await getRecoveryStatus(client, journal, 'invite') assertCommitted(reconciled, installed) + observedCommitted = true await publishCommitted(journal, reconciled, dependencies) return 'recovered' } @@ -138,6 +148,19 @@ async function runRecovery( client?.close() } } + // Why: past invite expiry no credential can still establish what happened, so + // retaining the journal cannot reconcile anything — it only fails every later + // pairing with "recovery pending" forever. Re-pairing mints a fresh device and + // any uncommitted server-side install expires on its own. The extra invite + // lifetime of slack keeps a brief relay outage from discarding a journal whose + // resume credential would have reconciled it on the next launch. + if ( + !observedCommitted && + journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now() + ) { + await dependencies.clearJournal(journal.metadata.journalId).catch(() => {}) + return 'abandoned' + } return 'deferred' } diff --git a/mobile/src/transport/settings-host-client-connections.ts b/mobile/src/transport/settings-host-client-connections.ts new file mode 100644 index 00000000000..b2341471d2b --- /dev/null +++ b/mobile/src/transport/settings-host-client-connections.ts @@ -0,0 +1,19 @@ +import { useCallback, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { useAllHostClients } from './use-all-host-clients' + +export function useFocusedSettingsHostClients(hostIds: string[]) { + const [focused, setFocused] = useState(false) + + useFocusEffect( + useCallback(() => { + setFocused(true) + return () => setFocused(false) + }, []) + ) + + const clients = useAllHostClients(focused ? hostIds : [], { + closeUnusedOnRelease: true + }) + return { clients, focused } +} diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts new file mode 100644 index 00000000000..7a23c86ac71 --- /dev/null +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -0,0 +1,776 @@ +import { createElement, Fragment, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAllHostClients } from './use-all-host-clients' +import { useFocusedSettingsHostClients } from './settings-host-client-connections' +import { + RpcClientProvider, + useHostClient, + usePrimeHosts, + useRpcClientContext, + type RpcClientContextValue +} from './client-context' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, HostProfile } from './types' + +const connectMock = vi.fn() +const loadHostsMock = vi.fn() +const routeFocus = vi.hoisted(() => ({ + effect: null as null | (() => void | (() => void)) +})) + +vi.mock('expo-router', () => ({ + useFocusEffect: (effect: () => void | (() => void)) => { + routeFocus.effect = effect + } +})) + +vi.mock('./host-logical-client', () => ({ + openHostLogicalClient: (...args: unknown[]) => connectMock(...args) +})) +vi.mock('./host-store', () => ({ + loadHosts: () => loadHostsMock() +})) +vi.mock('./connection-revival-triggers', () => ({ + subscribeConnectionRevivalTriggers: () => () => {} +})) + +type FakeClient = RpcClient & { + closeMock: ReturnType +} + +function makeFakeClient(initialState: ConnectionState): FakeClient { + const listeners = new Set<(state: ConnectionState) => void>() + const closeMock = vi.fn() + return { + sendRequest: vi.fn(), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => initialState, + getReconnectAttempt: () => (initialState === 'reconnecting' ? 4 : 0), + getLastConnectedAt: () => null, + onStateChange: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + notifyForeground: vi.fn(), + close: closeMock, + closeMock + } as FakeClient +} + +function host(id: string, lastConnected: number, extra: Partial = {}): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}.internal:8787`, + deviceToken: `token-${id}`, + publicKeyB64: `key-${id}`, + lastConnected, + ...extra + } +} + +const HOSTS = [ + host('direct-recent', 50), + host('relay-recent', 40, { relayHostId: 'AbCdEf0123_-xyZ9' }), + host('ssh-provider-recent', 30), + host('folder-workspace-host', 20), + host('offline-host', 10) +] +const HOST_IDS = HOSTS.map((profile) => profile.id) +const HOME_HOST_IDS = selectHomeAutoConnectHostIds(HOSTS) + +let context: RpcClientContextValue | null = null + +function ContextProbe(): null { + context = useRpcClientContext() + return null +} + +function HomeProbe(): null { + useAllHostClients(HOST_IDS, { + autoConnectHostIds: HOME_HOST_IDS, + closeUnusedOnRelease: true + }) + const primeHosts = usePrimeHosts() + useEffect(() => { + primeHosts(HOSTS) + }, [primeHosts]) + return null +} + +function SettingsProbe(): null { + useAllHostClients(HOST_IDS, { + closeUnusedOnRelease: true + }) + return null +} + +function FocusedSettingsProbe(): null { + useFocusedSettingsHostClients(HOST_IDS) + return null +} + +function DynamicSettingsProbe({ hostIds }: { hostIds: string[] }): null { + useAllHostClients(hostIds, { closeUnusedOnRelease: true }) + return null +} + +function DetailProbe({ hostId }: { hostId: string }): null { + useHostClient(hostId) + return null +} + +type Screen = 'empty' | 'home' | 'settings' + +function TestApp({ + screen, + detailHostId +}: { + screen: Screen + detailHostId?: string +}): React.JSX.Element { + const screenProbe = + screen === 'home' + ? createElement(HomeProbe) + : screen === 'settings' + ? createElement(SettingsProbe) + : null + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + detailHostId ? createElement(DetailProbe, { hostId: detailHostId }) : null, + screenProbe + ) + ) +} + +function suppressRendererWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +async function renderScreen(screen: Screen, detailHostId?: string): Promise { + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('settings lifecycle harness did not render') + } + return renderer +} + +async function navigate( + renderer: ReactTestRenderer, + screen: Screen, + detailHostId?: string +): Promise { + await act(async () => { + renderer.update(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) +} + +function activeHostIds(): string[] { + if (!context) { + throw new Error('client context was not captured') + } + return context + .getAllClients() + .map(({ hostId }) => hostId) + .sort() +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + context = null + routeFocus.effect = null + connectMock.mockReset() + loadHostsMock.mockReset() +}) + +describe('settings host client lifecycle', () => { + it('closes settings-only clients while a mounted Home owner keeps its bounded set', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient(profile.id === 'offline-host' ? 'reconnecting' : 'connected') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + function NavigationStack({ settingsVisible }: { settingsVisible: boolean }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + settingsVisible ? createElement(SettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(NavigationStack, { settingsVisible: false })) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('navigation stack harness did not render') + } + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + expect(connectMock).toHaveBeenCalledTimes(3) + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: true })) + await Promise.resolve() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + expect(connectMock).toHaveBeenCalledTimes(HOSTS.length) + expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length) + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: false })) + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + for (const hostId of HOME_HOST_IDS) { + expect(clients.get(hostId)).toHaveLength(1) + expect(clients.get(hostId)?.[0]?.closeMock).not.toHaveBeenCalled() + } + for (const hostId of ['folder-workspace-host', 'offline-host']) { + expect(clients.get(hostId)?.[0]?.closeMock).toHaveBeenCalledOnce() + } + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: true })) + await Promise.resolve() + await Promise.resolve() + }) + expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length) + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: false })) + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + for (const hostId of HOME_HOST_IDS) { + expect(clients.get(hostId)).toHaveLength(1) + } + for (const hostId of ['folder-workspace-host', 'offline-host']) { + expect(clients.get(hostId)).toHaveLength(2) + expect(clients.get(hostId)?.every((client) => client.closeMock.mock.calls.length === 1)).toBe( + true + ) + } + + act(() => renderer.unmount()) + }) + + it('leaves no zero-reference clients after repeated settings navigation', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const renderer = await renderScreen('settings') + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + + await navigate(renderer, 'empty') + expect(activeHostIds()).toEqual([]) + for (const hostClients of clients.values()) { + expect(hostClients).toHaveLength(1) + expect(hostClients[0]?.closeMock).toHaveBeenCalledOnce() + } + + await navigate(renderer, 'settings') + await navigate(renderer, 'empty') + expect(activeHostIds()).toEqual([]) + expect(connectMock).toHaveBeenCalledTimes(HOSTS.length * 2) + for (const hostClients of clients.values()) { + expect(hostClients).toHaveLength(2) + expect(hostClients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true) + } + + act(() => renderer.unmount()) + }) + + it('reconciles host-list changes without restarting retained or shared clients', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const retainedHostId = 'direct-recent' + const sharedHostId = 'relay-recent' + const removedHostId = 'folder-workspace-host' + const addedHostId = 'offline-host' + function HostListApp({ settingsHostIds }: { settingsHostIds: string[] }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(DetailProbe, { hostId: sharedHostId }), + createElement(DynamicSettingsProbe, { hostIds: settingsHostIds }) + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, sharedHostId, removedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('host-list lifecycle harness did not render') + } + + await act(async () => { + renderer?.update( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, addedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + + expect(connectMock).toHaveBeenCalledTimes(4) + expect(activeHostIds()).toEqual([addedHostId, retainedHostId, sharedHostId].sort()) + expect(clients.get(retainedHostId)?.closeMock).not.toHaveBeenCalled() + expect(clients.get(sharedHostId)?.closeMock).not.toHaveBeenCalled() + expect(clients.get(removedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(addedHostId)?.closeMock).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + expect(clients.get(retainedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(sharedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(addedHostId)?.closeMock).toHaveBeenCalledOnce() + }) + + it('does not close a settings client still held by an active consumer', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('connected') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const detailHostId = 'folder-workspace-host' + const renderer = await renderScreen('settings', detailHostId) + await navigate(renderer, 'home', detailHostId) + + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + expect(clients.get(detailHostId)?.closeMock).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + expect(clients.get(detailHostId)?.closeMock).toHaveBeenCalledOnce() + }) + + it('keeps a reconnect alive when another consumer remains after settings leaves', async () => { + const retryHost = host('ssh-retry-host', 1) + let resolveInitial: ((hosts: HostProfile[]) => void) | null = null + let resolveRetry: ((hosts: HostProfile[]) => void) | null = null + const initialLookup = new Promise((resolve) => { + resolveInitial = resolve + }) + const retryLookup = new Promise((resolve) => { + resolveRetry = resolve + }) + loadHostsMock.mockReturnValueOnce(initialLookup).mockReturnValueOnce(retryLookup) + const initialClient = makeFakeClient('connected') + const retryClient = makeFakeClient('reconnecting') + connectMock.mockReturnValueOnce(initialClient).mockReturnValueOnce(retryClient) + + function RetrySettingsProbe(): null { + useAllHostClients([retryHost.id], { + closeUnusedOnRelease: true + }) + return null + } + function RetryApp({ settingsVisible }: { settingsVisible: boolean }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(DetailProbe, { hostId: retryHost.id }), + settingsVisible ? createElement(RetrySettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(RetryApp, { settingsVisible: true })) + }) + } finally { + restore() + } + if (!renderer || !resolveInitial || !resolveRetry) { + throw new Error('retry lifecycle harness did not initialize') + } + await act(async () => { + resolveInitial?.([retryHost]) + await initialLookup + }) + if (!context) { + throw new Error('client context was not captured') + } + + const reconnect = context.forceReconnect(retryHost.id) + expect(initialClient.closeMock).toHaveBeenCalledOnce() + act(() => renderer?.update(createElement(RetryApp, { settingsVisible: false }))) + await act(async () => { + resolveRetry?.([retryHost]) + await retryLookup + await reconnect + }) + + expect(activeHostIds()).toEqual([retryHost.id]) + expect(retryClient.closeMock).not.toHaveBeenCalled() + act(() => renderer?.unmount()) + expect(retryClient.closeMock).toHaveBeenCalledOnce() + }) + + it('cancels a released open without cancelling a rapid replacement acquisition', async () => { + const settingsOnlyHost = host('settings-only-offline', 0, { + deviceToken: '', + publicKeyB64: '' + }) + let resolveFirst: ((hosts: HostProfile[]) => void) | null = null + let resolveSecond: ((hosts: HostProfile[]) => void) | null = null + const firstLookup = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondLookup = new Promise((resolve) => { + resolveSecond = resolve + }) + loadHostsMock.mockReturnValueOnce(firstLookup).mockReturnValueOnce(secondLookup) + const client = makeFakeClient('reconnecting') + connectMock.mockReturnValue(client) + + function PendingSettingsProbe(): null { + useAllHostClients([settingsOnlyHost.id], { + closeUnusedOnRelease: true + }) + return null + } + function PendingApp({ visible }: { visible: boolean }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + visible ? createElement(PendingSettingsProbe) : null + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(PendingApp, { visible: true })) + }) + } finally { + restore() + } + if (!renderer || !resolveFirst || !resolveSecond) { + throw new Error('pending settings harness did not initialize') + } + + act(() => renderer?.update(createElement(PendingApp, { visible: false }))) + act(() => renderer?.update(createElement(PendingApp, { visible: true }))) + await act(async () => { + resolveFirst?.([settingsOnlyHost]) + await firstLookup + }) + expect(connectMock).not.toHaveBeenCalled() + + await act(async () => { + resolveSecond?.([settingsOnlyHost]) + await secondLookup + }) + expect(connectMock).toHaveBeenCalledOnce() + + act(() => renderer?.update(createElement(PendingApp, { visible: false }))) + expect(client.closeMock).toHaveBeenCalledOnce() + act(() => renderer?.unmount()) + }) + + it('preserves Home ownership when re-pairing replaces a selected host client', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('connected') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + const replacedHostId = HOME_HOST_IDS[0]! + + function ReplacementApp({ + detailVisible, + settingsVisible + }: { + detailVisible: boolean + settingsVisible: boolean + }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + detailVisible ? createElement(DetailProbe, { hostId: replacedHostId }) : null, + settingsVisible ? createElement(SettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer || !context) { + throw new Error('replacement lifecycle harness did not initialize') + } + + const originalClient = clients.get(replacedHostId)?.[0] + act(() => context?.closeHost(replacedHostId)) + expect(originalClient?.closeMock).toHaveBeenCalledOnce() + + await act(async () => { + renderer?.update( + createElement(ReplacementApp, { detailVisible: true, settingsVisible: false }) + ) + await Promise.resolve() + await Promise.resolve() + }) + const replacementClient = clients.get(replacedHostId)?.[1] + expect(replacementClient).toBeDefined() + + await act(async () => { + renderer?.update( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: true }) + ) + await Promise.resolve() + await Promise.resolve() + }) + act(() => + renderer?.update( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + ) + + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + expect(replacementClient?.closeMock).not.toHaveBeenCalled() + act(() => renderer?.unmount()) + expect(replacementClient?.closeMock).toHaveBeenCalledOnce() + }) + + it('releases a manual host after Home demotes or stops tracking it', async () => { + const manualHost = host('manual-relay-host', 1, { relayHostId: 'AbCdEf0123_-xyZ9' }) + const clients: FakeClient[] = [] + connectMock.mockImplementation(() => { + const client = makeFakeClient('reconnecting') + clients.push(client) + return client + }) + loadHostsMock.mockResolvedValue([manualHost]) + + function ManualHomeProbe({ selected }: { selected: boolean }): null { + useAllHostClients([manualHost.id], { + autoConnectHostIds: selected ? [manualHost.id] : [], + closeUnusedOnRelease: true + }) + return null + } + function ManualApp({ + selected, + homeVisible = true + }: { + selected: boolean + homeVisible?: boolean + }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + homeVisible ? createElement(ManualHomeProbe, { selected }) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(ManualApp, { selected: false })) + }) + } finally { + restore() + } + if (!renderer || !context) { + throw new Error('manual connection harness did not initialize') + } + await act(async () => { + await context?.forceReconnect(manualHost.id) + }) + expect(activeHostIds()).toEqual([manualHost.id]) + + await act(async () => { + renderer?.update(createElement(ManualApp, { selected: true })) + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([manualHost.id]) + + await act(async () => { + renderer?.update(createElement(ManualApp, { selected: false })) + await Promise.resolve() + }) + + expect(activeHostIds()).toEqual([]) + expect(clients[0]?.closeMock).toHaveBeenCalledOnce() + + await act(async () => { + await context?.forceReconnect(manualHost.id) + }) + expect(activeHostIds()).toEqual([manualHost.id]) + expect(clients).toHaveLength(2) + + act(() => renderer?.update(createElement(ManualApp, { selected: false, homeVisible: false }))) + expect(activeHostIds()).toEqual([]) + expect(clients[1]?.closeMock).toHaveBeenCalledOnce() + + act(() => renderer?.unmount()) + expect(clients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true) + }) + + it('releases settings-only clients on blur while shared owners stay mounted', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + const detailHostId = 'folder-workspace-host' + + function FocusStack(): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + createElement(DetailProbe, { hostId: detailHostId }), + createElement(FocusedSettingsProbe) + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(FocusStack)) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer || !routeFocus.effect) { + throw new Error('settings focus harness did not initialize') + } + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + + let blur: (() => void) | undefined + await act(async () => { + const cleanup = routeFocus.effect?.() + if (typeof cleanup === 'function') { + blur = cleanup + } + await Promise.resolve() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + + await act(async () => { + blur?.() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + for (const hostId of [...HOME_HOST_IDS, detailHostId]) { + expect(clients.get(hostId)?.closeMock).not.toHaveBeenCalled() + } + expect(clients.get('offline-host')?.closeMock).toHaveBeenCalledOnce() + + act(() => renderer?.unmount()) + }) +}) diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 400deee90a6..9d96f0561ad 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -75,6 +75,13 @@ export type HostProfile = { relay?: MobileRelayHostOverlay['relay'] } +export type HostCredentialStatus = 'ready' | 'temporarily-unavailable' | 'missing' + +export type HostCatalogEntry = Omit & { + credentialStatus: HostCredentialStatus + profile: HostProfile | null +} + export const HostProfileSchema = z.object({ id: z.string().min(1), name: z.string().min(1), diff --git a/mobile/src/transport/unpaired-host-credential-deletion.ts b/mobile/src/transport/unpaired-host-credential-deletion.ts new file mode 100644 index 00000000000..cc9c27e49ad --- /dev/null +++ b/mobile/src/transport/unpaired-host-credential-deletion.ts @@ -0,0 +1,58 @@ +import { deleteHostDeviceToken } from './host-device-token-store' +import { + clearHostCredentialWriteRevision, + getHostCredentialWriteRevision +} from './host-credential-write-revision' +import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' + +type DeletionDependencies = { + waitForHostMutations: () => Promise + hasStoredHost: (hostId: string) => Promise + onDeleted: (hostId: string) => void +} + +export function createUnpairedHostCredentialDeletion(dependencies: DeletionDependencies) { + function writeRevisionChanged(hostId: string, writeRevision: number): boolean { + return getHostCredentialWriteRevision(hostId) !== writeRevision + } + + function assertWriteRevisionUnchanged(hostId: string, writeRevision: number): void { + if (writeRevisionChanged(hostId, writeRevision)) { + throw new Error('credential write superseded cleanup') + } + } + + async function shouldSkip(hostId: string, writeRevision: number): Promise { + await dependencies.waitForHostMutations() + if (await dependencies.hasStoredHost(hostId)) { + return true + } + assertWriteRevisionUnchanged(hostId, writeRevision) + return false + } + + return async (hostId: string, writeRevision: number): Promise => { + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteHostDeviceToken(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteMobileRelayCredentialBundle(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteMobileRelayDirectUpgradeJournal(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + clearHostCredentialWriteRevision(hostId) + dependencies.onDeleted(hostId) + } +} diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts index a1e429b45b0..1000916113b 100644 --- a/mobile/src/transport/use-all-host-clients.ts +++ b/mobile/src/transport/use-all-host-clients.ts @@ -1,56 +1,126 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState } from './types' import { useRpcClientContext } from './client-context' -// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. -export function useAllHostClients(hostIds: string[]) { +type UseAllHostClientsOptions = { + autoConnectHostIds?: readonly string[] + closeUnusedOnRelease?: boolean +} + +export function useAllHostClients(hostIds: string[], options?: UseAllHostClientsOptions) { const ctx = useRpcClientContext() - // Stable key so we don't tear down on every render of the array. - const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) + const autoConnectHostIds = options?.autoConnectHostIds ?? hostIds + const closeUnusedOnRelease = options?.closeUnusedOnRelease ?? false + const key = useMemo( + () => + [ + [...hostIds].sort().join(','), + [...autoConnectHostIds].sort().join(','), + closeUnusedOnRelease ? 'close' : 'keep' + ].join('|'), + [autoConnectHostIds, closeUnusedOnRelease, hostIds] + ) const [tick, setTick] = useState(0) + const acquiredHostIdsRef = useRef>(new Set()) + const hostUnsubscribesRef = useRef void>>(new Map()) + const closeUnusedRef = useRef(closeUnusedOnRelease) useEffect(() => { - if (hostIds.length === 0) { - return - } - for (const id of hostIds) { - ctx.acquire(id) - } - const unsubs: (() => void)[] = [] - for (const id of hostIds) { - unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1))) - } - unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1))) + closeUnusedRef.current = closeUnusedOnRelease + }, [closeUnusedOnRelease]) + + useEffect(() => { + const unsubscribeAllHosts = ctx.subscribeAllHosts(() => setTick((value) => value + 1)) return () => { - for (const u of unsubs) { - u() + unsubscribeAllHosts() + const trackedHostIds = [...hostUnsubscribesRef.current.keys()] + const acquiredHostIds = new Set(acquiredHostIdsRef.current) + for (const unsubscribe of hostUnsubscribesRef.current.values()) { + unsubscribe() } - for (const id of hostIds) { - ctx.release(id) + hostUnsubscribesRef.current.clear() + for (const id of acquiredHostIds) { + if (closeUnusedRef.current) { + ctx.releaseAndCloseIfUnused(id) + } else { + ctx.release(id) + } + } + if (closeUnusedRef.current) { + for (const id of trackedHostIds) { + if (!acquiredHostIds.has(id)) { + ctx.closeIfUnused(id) + } + } + } + acquiredHostIdsRef.current.clear() + } + }, [ctx]) + + useEffect(() => { + const trackedHostIds = new Set(hostIds) + const nextAcquiredHostIds = new Set(autoConnectHostIds.filter((id) => trackedHostIds.has(id))) + const removedTrackedHostIds: string[] = [] + + for (const [id, unsubscribe] of hostUnsubscribesRef.current) { + if (!trackedHostIds.has(id)) { + unsubscribe() + hostUnsubscribesRef.current.delete(id) + removedTrackedHostIds.push(id) } } - }, [key]) + for (const id of trackedHostIds) { + if (!hostUnsubscribesRef.current.has(id)) { + hostUnsubscribesRef.current.set( + id, + ctx.subscribeHostState(id, () => setTick((value) => value + 1)) + ) + } + } + + for (const id of acquiredHostIdsRef.current) { + if (!nextAcquiredHostIds.has(id)) { + if (closeUnusedOnRelease) { + ctx.releaseAndCloseIfUnused(id) + } else { + ctx.release(id) + } + } + } + for (const id of nextAcquiredHostIds) { + if (!acquiredHostIdsRef.current.has(id)) { + ctx.acquire(id) + } + } + if (closeUnusedOnRelease) { + for (const id of removedTrackedHostIds) { + ctx.closeIfUnused(id) + } + for (const id of trackedHostIds) { + if (!nextAcquiredHostIds.has(id)) { + ctx.closeIfUnused(id) + } + } + } + acquiredHostIdsRef.current = nextAcquiredHostIds + }, [ctx, key]) return useMemo(() => { - const out: { + const clientsByHostId = new Map( + ctx.getAllClients().map((entry) => [entry.hostId, entry.client]) + ) + return hostIds.flatMap<{ hostId: string client: RpcClient state: ConnectionState path: MobileConnectionPath - }[] = [] - for (const id of hostIds) { - const all = ctx.getAllClients().find((entry) => entry.hostId === id) - if (all) { - out.push({ - hostId: id, - client: all.client, - state: ctx.getState(id), - path: ctx.getActivePath(id) - }) - } - } - return out - }, [key, tick]) + }>((hostId) => { + const client = clientsByHostId.get(hostId) + return client + ? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }] + : [] + }) + }, [ctx, hostIds, tick]) } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 50ecae7d024..7ecbdbf004a 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -729,10 +729,16 @@ final class Provider { let snapshot = try currentSnapshot(params: params) let button = params["mouseButton"]?.string ?? "left" let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount") + guard count <= SyntheticMouseClickDelivery.maxClickCount else { + throw ProviderError.coded( + "invalid_argument", + "clickCount must be at most \(SyntheticMouseClickDelivery.maxClickCount)" + ) + } let modifiers = try KeyMap.parseModifiers(params["modifiers"]?.string) // Why: agents expect a click into a target app to make the next // keyboard action safe, even when the click uses an AX action path. - recoverWindow(snapshot.app) + recoverWindow(snapshot.app, windowId: snapshot.windowId, windowBounds: snapshot.windowBounds) if let elementIndex = try optionalInteger(params, "elementIndex") { let record = try element(snapshot, elementIndex) if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) { @@ -740,25 +746,32 @@ final class Provider { } if let point = center(record.localFrame, in: snapshot.windowBounds) { try Input.click( - pid: snapshot.app.pid, at: point, button: mouseButton(button), count: count, - modifiers: modifiers + modifiers: modifiers, + targetWindow: snapshot + ) + return actionMetadata( + path: "synthetic", + fallbackReason: "actionUnsupported", + verification: unverifiedAction(reason: "synthetic_input") ) - return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported") } throw ProviderError.coded("element_not_clickable", "element \(record.index) has no clickable frame") } let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot) try Input.click( - pid: snapshot.app.pid, at: point, button: mouseButton(button), count: count, - modifiers: modifiers + modifiers: modifiers, + targetWindow: snapshot + ) + return actionMetadata( + path: "synthetic", + verification: unverifiedAction(reason: "synthetic_input") ) - return actionMetadata(path: "synthetic") } private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? { @@ -1118,6 +1131,124 @@ private func isTargetWindowFocused(_ snapshot: Snapshot) -> Bool { return !intersection.isNull && intersection.area >= min(frame.area, snapshot.windowBounds.area) * 0.75 } +private func currentSyntheticClickRecipient( + snapshot: Snapshot, + point: CGPoint +) -> SyntheticMouseClickDelivery.Recipient? { + let target = syntheticClickRecipient(pid: snapshot.app.pid, windowId: snapshot.windowId) + var cachedTargetCandidates: [WindowCandidate]? + func targetCandidates() -> [WindowCandidate] { + if let cachedTargetCandidates { return cachedTargetCandidates } + let candidates = WindowCapture.candidates(pid: snapshot.app.pid) + cachedTargetCandidates = candidates + return candidates + } + if let focused = focusedSyntheticClickRecipient( + targetPID: snapshot.app.pid, + targetCandidates: targetCandidates + ) { + guard focused == target else { return focused } + } else { + guard NSWorkspace.shared.frontmostApplication?.processIdentifier == snapshot.app.pid else { + return nil + } + } + return hitTestSyntheticClickRecipient( + at: point, + targetPID: snapshot.app.pid, + targetCandidates: targetCandidates + ) +} + +private func focusedSyntheticClickRecipient( + targetPID: pid_t, + targetCandidates: () -> [WindowCandidate] +) -> SyntheticMouseClickDelivery.Recipient? { + let systemWide = AXUIElementCreateSystemWide() + guard let focusedApp = copyElement(systemWide, kAXFocusedApplicationAttribute as String), + let ownerPID = pidAttribute(focusedApp), + let focusedWindow = copyElement(systemWide, kAXFocusedWindowAttribute as String) ?? + copyElement(focusedApp, kAXFocusedWindowAttribute as String) + else { + return nil + } + if let windowId = windowNumber(focusedWindow) { + return syntheticClickRecipient(pid: ownerPID, windowId: windowId) + } + guard ownerPID == targetPID else { return nil } + guard let frame = absoluteFrame(focusedWindow), + let candidate = SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: targetCandidates(), + matching: { + windowFramesMatch($0.bounds, frame) + } + ) + else { + return nil + } + return syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId) +} + +private func hitTestSyntheticClickRecipient( + at point: CGPoint, + targetPID: pid_t, + targetCandidates: () -> [WindowCandidate] +) -> SyntheticMouseClickDelivery.Recipient? { + let systemWide = AXUIElementCreateSystemWide() + var hitElement: AXUIElement? + guard AXUIElementCopyElementAtPosition( + systemWide, + Float(point.x), + Float(point.y), + &hitElement + ) == .success, + let hitElement, + let ownerPID = pidAttribute(hitElement), + let window = containingWindow(hitElement) + else { + return nil + } + if let windowId = windowNumber(window) { + return syntheticClickRecipient(pid: ownerPID, windowId: windowId) + } + guard ownerPID == targetPID else { return nil } + guard let frame = absoluteFrame(window), + let candidate = SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: targetCandidates(), + matching: { + windowFramesMatch($0.bounds, frame) + } + ) + else { + return nil + } + return syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId) +} + +private func containingWindow(_ element: AXUIElement) -> AXUIElement? { + var current = element + for _ in 0..<64 { + if stringAttribute(current, kAXRoleAttribute as String) == kAXWindowRole as String { + return current + } + if let window = copyElement(current, kAXWindowAttribute as String) { + return window + } + guard let parent = copyElement(current, kAXParentAttribute as String) else { + return nil + } + current = parent + } + return nil +} + +private func syntheticClickRecipient( + pid: pid_t, + windowId: CGWindowID +) -> SyntheticMouseClickDelivery.Recipient { + SyntheticMouseClickDelivery.Recipient(ownerPID: pid, windowID: windowId) +} + private func requireTargetWindowFocused(_ snapshot: Snapshot, restoreWindowRequested: Bool) throws { guard let failure = KeyboardInputSafety.syntheticInputFocusFailure( targetWindowFocused: isTargetWindowFocused(snapshot), @@ -1158,14 +1289,44 @@ private func matchingWindow(appElement: AXUIElement, capture: WindowCapture, foc } ?? focused } -private func recoverWindow(_ app: AppDescriptor) { +private func recoverWindow( + _ app: AppDescriptor, + windowId: CGWindowID? = nil, + windowBounds: CGRect? = nil +) { _ = app.app.unhide() _ = app.app.activate(options: [.activateAllWindows]) if let bundleId = app.bundleId { openBundle(bundleId) } let appElement = AXUIElementCreateApplication(app.pid) - if let window = copyElement(appElement, kAXFocusedWindowAttribute as String) ?? copyArray(appElement, kAXWindowsAttribute as String)?.first { + let focusedWindow = copyElement(appElement, kAXFocusedWindowAttribute as String) + var cachedWindows: [AXUIElement]? + func windows() -> [AXUIElement] { + if let cachedWindows { return cachedWindows } + let value = copyArray(appElement, kAXWindowsAttribute as String) ?? [] + cachedWindows = value + return value + } + let targetWindow: AXUIElement? + if let focusedWindow, + (windowId == nil && windowBounds == nil || windowMatchesCapture( + focusedWindow, + windowId: windowId, + windowBounds: windowBounds + )) { + targetWindow = focusedWindow + } else { + let exactWindow = windowId.flatMap { targetId in + windows().first { windowNumber($0) == targetId } + } + targetWindow = exactWindow ?? windowBounds.flatMap { targetBounds in + windows().first { window in + absoluteFrame(window).map { windowFramesMatch($0, targetBounds) } == true + } + } + } + if let window = targetWindow ?? focusedWindow ?? windows().first { _ = AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, kCFBooleanFalse) _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString) _ = AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, kCFBooleanTrue) @@ -1174,6 +1335,24 @@ private func recoverWindow(_ app: AppDescriptor) { Thread.sleep(forTimeInterval: 0.4) } +private func windowMatchesCapture( + _ window: AXUIElement, + windowId: CGWindowID?, + windowBounds: CGRect? +) -> Bool { + if let windowId, windowNumber(window) == windowId { return true } + guard let windowBounds, let frame = absoluteFrame(window) else { return false } + return windowFramesMatch(frame, windowBounds) +} + +private func windowFramesMatch(_ lhs: CGRect, _ rhs: CGRect) -> Bool { + let tolerance: CGFloat = 2 + return abs(lhs.minX - rhs.minX) <= tolerance && + abs(lhs.minY - rhs.minY) <= tolerance && + abs(lhs.width - rhs.width) <= tolerance && + abs(lhs.height - rhs.height) <= tolerance +} + private func openBundle(_ bundleId: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") @@ -2286,11 +2465,11 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? { private enum Input { static func click( - pid: pid_t, at point: CGPoint, button: MouseButton, count: Int, - modifiers: [KeyModifier] + modifiers: [KeyModifier], + targetWindow: Snapshot ) throws { guard let source = CGEventSource(stateID: .combinedSessionState) else { throw ProviderError.coded("accessibility_error", "failed to create event source") @@ -2298,10 +2477,56 @@ private enum Input { let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in result.insert(modifier.flag) } - for _ in 0.. 0 { + event.setIntegerValueField(.mouseEventClickState, value: clickState) + } + return event + }, + post: { $0.post(tap: .cghidEventTap) }, + pause: { _ = usleep($0) } + ) + } catch let failure as SyntheticMouseClickDelivery.FenceFailure { + switch failure { + case let .recipientChanged(expected, actual, deliveredPresses): + let actualDescription = actual.map { + "pid \($0.ownerPID) window \($0.windowID)" + } ?? "no focused window" + let recovery = deliveredPresses == 0 + ? "bring the target window forward, run get-app-state again, and retry" + : "\(deliveredPresses) press(es) may already have been delivered; run get-app-state and verify state before retrying" + throw ProviderError.coded( + "window_not_focused", + "coordinate click aborted because target pid \(expected.ownerPID) window \(expected.windowID) is no longer the focused topmost recipient (current: \(actualDescription)); \(recovery)" + ) + } } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift new file mode 100644 index 00000000000..13aa6e15ced --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift @@ -0,0 +1,104 @@ +/// Event plan for synthetic mouse clicks (STA-3433). +/// +/// Clicks must be posted to the HID event tap, not `CGEventPostToPid`: +/// pid-targeted mouse events reach the app with no window association, so +/// AppKit never routes the press to a view (hover fires, activation never +/// happens). The window server also drops a mouseUp posted back-to-back +/// with its mouseDown, so consecutive events need a pause between them. +public enum SyntheticMouseClickDelivery { + public static let maxClickCount = 3 + + public struct Recipient: Equatable, Sendable { + public let ownerPID: Int32 + public let windowID: UInt32 + + public init(ownerPID: Int32, windowID: UInt32) { + self.ownerPID = ownerPID + self.windowID = windowID + } + } + + public enum FenceFailure: Error, Equatable { + case recipientChanged(expected: Recipient, actual: Recipient?, deliveredPresses: Int) + } + + public enum Step: Equatable { + case move + case buttonDown(pressIndex: Int) + case buttonUp(pressIndex: Int) + } + + /// Pause after posting each event; unpaced posts race the window + /// server's routing and the mouseUp is silently dropped. + public static let interEventPauseMicroseconds: UInt32 = 50_000 + + /// One move, then a paired down/up per press. `pressIndex` becomes the + /// event's click state so repeated presses register as double/triple + /// clicks instead of independent single clicks. + public static func steps(clickCount: Int) -> [Step] { + var steps: [Step] = [.move] + for press in 1...min(max(clickCount, 1), maxClickCount) { + steps.append(.buttonDown(pressIndex: press)) + steps.append(.buttonUp(pressIndex: press)) + } + return steps + } + + /// Click state field value for a step; 0 leaves the field unset. + public static func clickState(for step: Step) -> Int64 { + switch step { + case .move: + return 0 + case let .buttonDown(pressIndex), let .buttonUp(pressIndex): + return Int64(pressIndex) + } + } + + public static func uniqueWindowCandidate( + from candidates: [Candidate], + matching predicate: (Candidate) -> Bool + ) -> Candidate? { + var match: Candidate? + for candidate in candidates where predicate(candidate) { + guard match == nil else { return nil } + match = candidate + } + return match + } + + public static func deliver( + clickCount: Int, + target: Recipient, + currentRecipient: () -> Recipient?, + makeEvent: (Step) throws -> Event, + post: (Event) -> Void, + pause: (UInt32) -> Void + ) throws { + post(try makeEvent(.move)) + pause(interEventPauseMicroseconds) + for pressIndex in 1...min(max(clickCount, 1), maxClickCount) { + let beforeDown = currentRecipient() + guard beforeDown == target else { + throw FenceFailure.recipientChanged( + expected: target, + actual: beforeDown, + deliveredPresses: pressIndex - 1 + ) + } + let down = try makeEvent(.buttonDown(pressIndex: pressIndex)) + let up = try makeEvent(.buttonUp(pressIndex: pressIndex)) + post(down) + pause(interEventPauseMicroseconds) + post(up) + let afterUp = currentRecipient() + guard afterUp == target else { + throw FenceFailure.recipientChanged( + expected: target, + actual: afterUp, + deliveredPresses: pressIndex + ) + } + pause(interEventPauseMicroseconds) + } + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift new file mode 100644 index 00000000000..5870e740880 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift @@ -0,0 +1,233 @@ +import XCTest +@testable import OrcaComputerUseMacOSCore + +final class SyntheticMouseClickDeliveryTests: XCTestCase { + func testSingleClickPlanPairsDownAndUpAfterMove() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: 1), + [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)] + ) + } + + func testMultiClickPlanNumbersEachPressForClickState() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: 2), + [ + .move, + .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1), + .buttonDown(pressIndex: 2), .buttonUp(pressIndex: 2), + ] + ) + } + + func testNonPositiveClickCountStillDeliversOnePress() { + for count in [0, -3] { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: count), + [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)] + ) + } + } + + func testExcessiveClickCountIsCappedAtTripleClick() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: Int.max), + [ + .move, + .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1), + .buttonDown(pressIndex: 2), .buttonUp(pressIndex: 2), + .buttonDown(pressIndex: 3), .buttonUp(pressIndex: 3), + ] + ) + } + + func testClickStateMatchesPressIndexAndSkipsMove() { + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .move), 0) + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonDown(pressIndex: 1)), 1) + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonUp(pressIndex: 2)), 2) + } + + func testInterEventPauseIsNonZero() { + // Unpaced posts race the window server and the mouseUp is dropped, + // turning the click into a hover-only no-op (STA-3433). + XCTAssertGreaterThan(SyntheticMouseClickDelivery.interEventPauseMicroseconds, 0) + } + + func testAmbiguousWindowFrameFallbackDoesNotSelectRecipient() { + let candidates = [(windowID: 101, frame: 7), (windowID: 202, frame: 7)] + + XCTAssertNil(SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.frame == 7 } + )) + XCTAssertNil(SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.frame == 8 } + )) + XCTAssertEqual( + SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.windowID == 101 } + )?.windowID, + 101 + ) + } + + func testRecipientChangeBeforeMouseDownPostsNoClickAndReportsBothWindows() { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { intruder }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 0) + ) + } + XCTAssertEqual(posted, [.move]) + } + + func testRecipientChangeAfterMouseUpStopsUntilStateIsVerified() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var recipients = [target, intruder, target, target] + var firstAttempt: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { firstAttempt.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 1) + ) + } + XCTAssertEqual(firstAttempt, [ + .move, + .buttonDown(pressIndex: 1), + .buttonUp(pressIndex: 1), + ]) + + var retry: [SyntheticMouseClickDelivery.Step] = [] + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { retry.append($0) }, + pause: { _ in } + ) + XCTAssertEqual(retry, [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)]) + } + + func testMouseUpPostsBeforeSecondRecipientCheck() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var trace: [String] = [] + + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { + trace.append("recipient") + return target + }, + makeEvent: { $0 }, + post: { + switch $0 { + case .move: + trace.append("move") + case .buttonDown: + trace.append("down") + case .buttonUp: + trace.append("up") + } + }, + pause: { _ in } + ) + + XCTAssertEqual(trace, ["move", "recipient", "down", "up", "recipient"]) + } + + func testRecipientChangeBeforeLaterPressReportsCompletedPresses() { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var recipients = [target, target, intruder] + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 2, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 1) + ) + } + XCTAssertEqual(posted, [ + .move, + .buttonDown(pressIndex: 1), + .buttonUp(pressIndex: 1), + ]) + } + + func testMultiClickRevalidatesBeforeEveryPressAndAfterEveryRelease() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var validationCount = 0 + var posted: [SyntheticMouseClickDelivery.Step] = [] + + try SyntheticMouseClickDelivery.deliver( + clickCount: 2, + target: target, + currentRecipient: { + validationCount += 1 + return target + }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + + XCTAssertEqual(validationCount, 4) + XCTAssertEqual(posted, SyntheticMouseClickDelivery.steps(clickCount: 2)) + } + + func testButtonPairIsPreparedBeforeMouseDownPosts() { + enum PreparationFailure: Error { case mouseUp } + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError(try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { target }, + makeEvent: { step in + if case .buttonUp = step { throw PreparationFailure.mouseUp } + return step + }, + post: { posted.append($0) }, + pause: { _ in } + )) + XCTAssertEqual(posted, [.move]) + } +} diff --git a/package.json b/package.json index cbc7862f8fd..75a8ad9acef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.169-rc.0", + "version": "1.4.176-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 7da8936ea1a..bc7b1cbbe3e 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -192,6 +192,14 @@ orca orchestration worker-start --task --worktree current --agent claud `current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `. +For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt: + +```bash +orca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json +``` + +`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option. + For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal: ```bash diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index fadfd439030..1fcb8e0afd1 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index 95a0a7db313..a2777ec3fdb 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -8,6 +8,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) import { ORCHESTRATION_HANDLERS } from './orchestration' import { printResult } from '../format' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' describe('orchestration worker-start CLI contract', () => { beforeEach(() => { @@ -84,6 +85,63 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBeUndefined() }) + it('capability-gates and forwards per-invocation launch preferences', async () => { + callMock + .mockResolvedValueOnce({ + result: { + capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY] + } + }) + .mockResolvedValueOnce({ + result: { + runId: 'run_1', + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + effects: [], + residualResources: [] + } + }) + + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'claude'], + ['model', 'aws-bedrock-opus-5'], + ['effort', 'high'], + ['from', 'term_coord'] + ]) + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'orchestration.workerStart', + expect.objectContaining({ + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + }) + ) + }) + + it('fails before worker-start when the runtime would strip launch preferences', async () => { + callMock.mockResolvedValueOnce({ result: { capabilities: [] } }) + + await expect( + invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'codex'], + ['model', 'gpt-5.6-sol'], + ['from', 'term_coord'] + ]) + ) + ).rejects.toMatchObject({ code: 'incompatible_runtime' }) + + expect(callMock).toHaveBeenCalledTimes(1) + }) + it('sets an unsuccessful exit code for failed and unknown receipts', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index f9cbdb8a41a..6668b4e1906 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -20,7 +20,8 @@ import type { OrchestrationWorkerReadSource } from '../../shared/orchestration-worker-output' import type { NativeChatMessage } from '../../shared/native-chat-types' -import type { RuntimeTerminalRead } from '../../shared/runtime-types' +import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' import { @@ -852,6 +853,21 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const model = getOptionalStringFlag(flags, 'model') + const effort = getOptionalStringFlag(flags, 'effort') + if (model || effort) { + const status = await client.call('status.get') + if ( + !status.result.capabilities?.includes( + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY + ) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' + ) + } + } const result = await callMutation<{ runId: string taskId: string @@ -873,6 +889,8 @@ export const ORCHESTRATION_HANDLERS: Record = { comment: getOptionalStringFlag(flags, 'comment'), setup: getOptionalStringFlag(flags, 'setup'), agent: getOptionalStringFlag(flags, 'agent'), + model, + effort, terminal: getOptionalStringFlag(flags, 'terminal'), retryOf: getOptionalStringFlag(flags, 'retry-of'), timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), diff --git a/src/cli/help.ts b/src/cli/help.ts index e866a91ef22..3415ddad832 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -552,6 +552,8 @@ export function formatFlagHelp(flag: string): string { local: '--local Target the current project instead of the global install', skill: '--skill Bundled skill to act on; repeat for several', mode: '--mode Mode such as edit, diff, or both', + model: '--model Provider model id for a new agent launch', + effort: '--effort Reasoning effort for the selected model', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', modifiers: '--modifiers Modifier keys held only for this click', name: '--name Name for the new worktree or automation', diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 2764a239021..7cba4d1cdc1 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -17,6 +17,7 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY @@ -61,7 +62,10 @@ describe('CLI remote WebSocket transport', () => { expect(response.result.runtimeId).toBe('runtime-ws-1') expect(runtime.authFrames).toContainEqual( expect.objectContaining({ - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) ) }) diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts new file mode 100644 index 00000000000..1abcf84ef64 --- /dev/null +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { normalizeServeModeArgv } from '../main/startup/serve-mode-argv' +import { BOOLEAN_FLAGS, GLOBAL_FLAGS } from './args' +import { SERVE_COMMAND_SPECS } from './specs/serve' + +// Why this test lives under src/cli: the Electron rewrite duplicates the CLI's serve flag list because +// the main tsconfig cannot import the CLI project, so the parity check has to run from the CLI side. +// It fails when `orca serve` grows a flag that a direct ` serve …` launch would silently drop (#12677). + +// Global flags with no `--serve-*` counterpart: +// - help: a help launch is refused outright, not translated (see serve-mode-argv's HELP_FLAGS). +// - pairing-code / environment: nothing in src/main reads them, so they ride through untouched. +const UNTRANSLATED_GLOBAL_FLAGS = new Set(['help', 'pairing-code', 'environment']) + +const serveSpec = SERVE_COMMAND_SPECS.find((spec) => spec.path.join(' ') === 'serve') +const translatedFlags = [...new Set(serveSpec?.allowedFlags ?? [])].filter( + (flag) => !UNTRANSLATED_GLOBAL_FLAGS.has(flag) +) + +describe('serve flag parity between the CLI spec and the Electron argv rewrite', () => { + it('has a serve spec to compare against', () => { + expect(translatedFlags.length).toBeGreaterThan(0) + }) + + it.each(translatedFlags)('rewrites CLI-form --%s into the --serve-* form', (flag) => { + const takesValue = !BOOLEAN_FLAGS.has(flag) + const argv = takesValue + ? ['/AppRun', 'serve', `--${flag}`, 'value'] + : ['/AppRun', 'serve', `--${flag}`] + const expected = takesValue + ? ['/AppRun', '--serve', `--serve-${flag}`, 'value'] + : ['/AppRun', '--serve', `--serve-${flag}`] + + expect(normalizeServeModeArgv(argv)).toEqual(expected) + if (takesValue) { + // The equals form is the other shape `orca serve` accepts, and getServeOptions only reads the next token. + expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=value`])).toEqual(expected) + } else { + // A boolean with an attached value is not a truthy assertion: the CLI reads these as + // `flags.get(name) === true`, so `--no-pairing=false` must not disable pairing here either. + // (`--json` is the one flag the CLI reads with `.has()`; erring toward the literal value is + // the safe direction and only changes output format.) + expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=false`])).toEqual([ + '/AppRun', + '--serve', + `--${flag}=false` + ]) + } + // Idempotent: `orca serve` spawns the app already in this shape, and the rewrite runs over it too. + expect(normalizeServeModeArgv(expected)).toEqual(expected) + }) + + it('emits the same --serve-* names the CLI spawns with and the main process reads', () => { + // Why source text: serveOrcaApp spawns a real process and getServeOptions is not exported, so + // both ends of the contract are only readable statically. Without this leg the rewrite could + // emit a name nothing reads and every behavioural assertion above would still pass. + const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') + const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const start = mainSource.indexOf('function getServeOptions(') + // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. + expect(start).toBeGreaterThanOrEqual(0) + const end = mainSource.indexOf('\n}', start) + expect(end).toBeGreaterThan(start) + const getServeOptionsBody = mainSource.slice(start, end) + + for (const flag of translatedFlags) { + expect(launchSource).toContain(`'--serve-${flag}'`) + expect(getServeOptionsBody).toContain(`'--serve-${flag}'`) + } + }) + + it('keeps the untranslated allowlist tied to real global flags', () => { + for (const flag of UNTRANSLATED_GLOBAL_FLAGS) { + expect(GLOBAL_FLAGS).toContain(flag) + } + }) +}) diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index a8d8b2411f5..b7f6b583a73 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'worker-start'], summary: 'Start one supervised worker on the Run home or a connected Orca server', usage: - 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', + 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'task', @@ -18,6 +18,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'comment', 'setup', 'agent', + 'model', + 'effort', 'terminal', 'retry-of', 'timeout-ms', @@ -27,6 +29,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ ], notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', + '--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', '--on selects only the worker server; the Run and this command remain on the current Orca server.', diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index e0688b44345..a6d5d61fd41 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -161,7 +161,14 @@ export function wrapWindowsGitBashHookCommand(scriptPath: string): string { : wrapWindowsHookCommand(scriptPath) } -export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { +/** + * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). + * Used by Grok to attach `grokHome` without fragile string replace on the shared template. + */ +export function buildWindowsAgentHookPostCommand( + source: AgentHookSource, + extraFormLines: readonly string[] = [] +): string { // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path. // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads. return [ @@ -175,6 +182,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin ' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^', ' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^', ' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^', + ...extraFormLines, ' --data-urlencode "payload@-" >nul 2>nul' ].join('\r\n') } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 96bb121c7e7..c1870d35a7c 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2257,6 +2257,36 @@ describe('AgentHookServer listener replay', () => { } }) + it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) + }) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) + server.retirePaneAuthority(PANE) + + // Why: `claude --resume` in the reused shell pane emits only SessionStart while idle; + // without the un-retire the resumed session stays rowless until a prompt (STA-3386). + await postHook({ hook_event_name: 'SessionStart', source: 'resume' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true }) + ]) + } finally { + server.stop() + } + }) + it('accepts a live remote prompt but not replay after launch authority retires', () => { const server = new AgentHookServer() server.ingestRemote( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f9e0a7812d..013b90d7143 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -415,7 +415,10 @@ function equivalentParsedAgentStatusPayload( a.toolInput === b.toolInput && a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && - a.interrupted === b.interrupted + a.interrupted === b.interrupted && + // Why: a session-boundary done must never be deduped against a cached real done — + // the flag has to reach receivers deterministically (STA-3386). + a.sessionBoundary === b.sessionBoundary ) } @@ -996,7 +999,12 @@ export class AgentHookServer { return 'accept' } // Why: command completion retires launch authority but leaves its shell pane reusable. - if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) { + // A live SessionStart proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + if ( + (event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') && + event.isReplay !== true + ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) return 'restart' diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts new file mode 100644 index 00000000000..49577d3e519 --- /dev/null +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../../shared/ai-vault-types' + +const { scanAiVaultSessions } = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn() })) + +vi.mock('./session-scanner', () => ({ scanAiVaultSessions })) +vi.mock('../wsl', () => ({ + getWslHomeAsync: vi.fn(), + listWslDistrosAsync: vi.fn().mockResolvedValue([]) +})) + +import { + invalidateAiVaultSessionListCache, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from './cached-session-list' + +function scanResult(scannedAt: string): AiVaultListResult { + return { sessions: [], issues: [], scannedAt } +} + +// A scan whose resolution the test controls, so an invalidation can be injected +// mid-flight. +function deferredScan(): { resolve: (value: AiVaultListResult) => void } { + let resolveFn: (value: AiVaultListResult) => void = () => {} + scanAiVaultSessions.mockReturnValueOnce( + new Promise((resolve) => { + resolveFn = resolve + }) + ) + return { resolve: resolveFn } +} + +describe('invalidateAiVaultSessionListCache generation guard', () => { + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessions.mockReset() + }) + afterEach(() => { + resetAiVaultSessionListCacheForTests() + }) + + it('does not let a scan that started before an invalidation repopulate the cache', async () => { + // Scan A starts and is still in flight. + const scanA = deferredScan() + const inFlight = listAiVaultSessions() + + // A delete invalidates the cache while A is running. + invalidateAiVaultSessionListCache() + + // A now resolves with a pre-delete result. + scanA.resolve(scanResult('scan-A')) + await inFlight + + // A non-force list must re-scan (cache empty) rather than serve A's stale + // result — proof A's late .then() did not repopulate the cache. + scanAiVaultSessions.mockResolvedValueOnce(scanResult('scan-B')) + const next = await listAiVaultSessions() + + expect(next.scannedAt).toBe('scan-B') + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + }) + + it('caches normally when no invalidation interrupts the scan', async () => { + scanAiVaultSessions.mockResolvedValueOnce(scanResult('scan-A')) + await listAiVaultSessions() + + // Second non-force call is a cache hit — no second scan. + const cached = await listAiVaultSessions() + + expect(cached.scannedAt).toBe('scan-A') + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index facf417cdbb..6a92bedb844 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -14,7 +14,7 @@ import { // Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime // RPC method share a single cache instance — opening the desktop panel and the // mobile screen for the same scope must not double-scan hundreds of transcripts. -const AI_VAULT_CACHE_TTL_MS = 15_000 +const AI_VAULT_CACHE_TTL_MS = 60_000 // Why: codex-home + WSL home dirs must be sourced from a serve-mode-reachable // seam (the OrcaRuntimeService deps), NOT the window-only registerCoreHandlers @@ -34,6 +34,11 @@ type CachedAiVaultList = { let cachedList: CachedAiVaultList | null = null let scanCoordinator = new AiVaultScanCoordinator() let sources: AiVaultSessionSources = {} +// Bumped on every invalidation. A scan that started before an invalidation +// carries the old generation and must not write its (now stale) result back +// into the cache — otherwise a delete's invalidation is silently undone by an +// in-flight scan that resolves just after it. +let cacheGeneration = 0 export function configureAiVaultSessionSources(next: AiVaultSessionSources): void { sources = next @@ -58,6 +63,10 @@ export async function listAiVaultSessions( ) { return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) } + // Captured here, not inside start(): the coordinator defers start() by a + // microtask, so an invalidation landing in that gap would otherwise be read + // as having happened before this scan and leave the stale result cacheable. + const startGeneration = cacheGeneration return scanCoordinator.run({ key: scanKey, force: args?.force, @@ -78,7 +87,10 @@ export async function listAiVaultSessions( // runtime id get the result restamped at the RPC edge, never rescanned. executionHostId: LOCAL_EXECUTION_HOST_ID }) - if (!scanSignal.aborted) { + // A delete (or other invalidation) landed while this scan was running: + // its result predates the delete, so caching it would resurrect the + // deleted session for the TTL. Return it to this caller but don't cache. + if (!scanSignal.aborted && startGeneration === cacheGeneration) { const current = cachedList if ( args?.force === true || @@ -111,9 +123,20 @@ export async function getAiVaultWslHomeDirs(): Promise { return homes.filter((homeDir): homeDir is string => Boolean(homeDir)) } +// Drops the scan-result cache after a session is deleted so a non-force +// list call within the TTL can't still serve the trashed session. Bumping the +// generation also disarms any scan already in flight, so a scan that started +// before this call cannot write its pre-delete result back into the cache. +// Scan discovery itself walks disk first (session-scanner.ts), so a deleted +// file is never rediscovered — this only guards the cached RESULT. +export function invalidateAiVaultSessionListCache(): void { + cacheGeneration++ + cachedList = null +} + // Why: tests reset module-level cache/source state between cases. export function resetAiVaultSessionListCacheForTests(): void { - cachedList = null + invalidateAiVaultSessionListCache() scanCoordinator = new AiVaultScanCoordinator() sources = {} } diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index a309f51077b..91f21eab720 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -3,7 +3,6 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' @@ -25,9 +24,7 @@ export async function discoverRemoteSourceCandidates(args: { const walked = args.source.fixedChildFileSegments ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) : await walkRemoteSessionFiles(args.source, args.context, args.issues) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null + const partition = args.source.partitionSubagentTranscripts?.(walked) ?? null const paths = partition ? partition.sessionFilePaths : walked const files = await mapRemoteScanBatches( paths, diff --git a/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts new file mode 100644 index 00000000000..8185765e4d2 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' + +describe('scanRemoteAiVaultSessions OMP subagent partitioning', () => { + it('excludes remote OMP artifact-dir transcripts and counts them on the parent', async () => { + const stem = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' + const workspaceDir = '/home/ada/.omp/agent/sessions/home-app-85dfa2f0' + const provider = new MemoryRemoteProvider() + provider.addFile( + `${workspaceDir}/${stem}.jsonl`, + jsonLines([ + { + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/home/ada/repo', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Delegate some tasks' } + } + ]), + 70 + ) + for (const label of ['AuthAndPreflight', 'BitbucketDcApi']) { + provider.addFile( + `${workspaceDir}/${stem}/${label}.jsonl`, + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:01:00.000Z', + message: { role: 'user', content: `${label} task prompt` } + } + ]), + 71 + ) + } + // A grandchild belongs to its own parent: neither a row nor a count here. + provider.addFile( + `${workspaceDir}/${stem}/AuthAndPreflight/Nested.jsonl`, + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:02:00.000Z', + message: { role: 'user', content: 'Nested task prompt' } + } + ]), + 72 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'omp', + sessionId: 'cccccccc-dddd-4eee-8fff-000000000000', + subagentTranscriptCount: 2, + filePath: `${workspaceDir}/${stem}.jsonl` + }) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index d704c2da671..debdc3881fc 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -12,6 +12,8 @@ import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent } from './session-scanner-copilot-parser' import { parseCursorSessionContent } from './session-scanner-cursor-parser' import { parseHermesSessionContent } from './session-scanner-hermes-parser' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' @@ -49,7 +51,7 @@ export function remoteSessionSources( // subagent counts instead. Partitioning also prunes the subagent // transcripts themselves, which would otherwise list as phantom // top-level sessions carrying the parent's sessionId. - collectSubagentSiblingCounts: true + partitionSubagentTranscripts: partitionSubagentTranscriptPaths }, remoteAntigravitySource(remoteHome, hostPlatform), source( @@ -92,7 +94,13 @@ export function remoteSessionSources( parseDevinSessionContent ), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), - jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), + { + ...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), + // Same posture as Claude above: OMP stores task-subagent transcripts in + // the session's same-named artifact dir; the walk supplies counts and the + // partition keeps the children out of the top-level list (#9330). + partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths + }, jsonlSource( 'droid', remoteHome, diff --git a/src/main/ai-vault/remote-session-scanner-test-fixtures.ts b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts new file mode 100644 index 00000000000..c3496590168 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts @@ -0,0 +1,100 @@ +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' + +// Shared by the remote AI Vault scanner suites: an in-memory filesystem whose +// directory tree is derived from the file paths added to it. +export class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 58fb255e3cc..8c9774abe14 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' +import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { @@ -36,9 +37,10 @@ export type RemoteSessionSource = { directoryPredicate?: (name: string, depth: number) => boolean // A canonical file directly beneath every top-level session directory. fixedChildFileSegments?: readonly string[] - // Claude layout: count `/subagents/*.jsonl` siblings from the walked - // listing and drop them from candidates instead of indexing them as sessions. - collectSubagentSiblingCounts?: boolean + // Sibling-subagent layouts (Claude `/subagents/`, OMP's same-named + // artifact dir): count subagent transcripts from the walked listing and drop + // them from candidates instead of indexing them as sessions. + partitionSubagentTranscripts?: (paths: readonly string[]) => SubagentTranscriptPartition parse: ( file: FileWithMtime, content: string, diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a4705ee1bfb..462ced9aa72 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -1,104 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { DirEntry } from '../../shared/types' -import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' - -class MemoryRemoteProvider implements IFilesystemProvider { - private readonly files = new Map() - private readonly readDirErrors = new Map() - private readonly statErrors = new Map() - readonly readDirPaths: string[] = [] - - addFile(path: string, content: string, mtimeMs: number): void { - this.files.set(normalize(path), { content, mtimeMs }) - } - - failStat(path: string, error: Error): void { - this.statErrors.set(normalize(path), error) - } - - failReadDir(path: string, error: Error): void { - this.readDirErrors.set(normalize(path), error) - } - - async readDir(dirPath: string): Promise { - const dir = normalize(dirPath) - this.readDirPaths.push(dir) - const readDirError = this.readDirErrors.get(dir) - if (readDirError) { - throw readDirError - } - const prefix = dir.endsWith('/') ? dir : `${dir}/` - const entries = new Map() - for (const path of this.files.keys()) { - if (!path.startsWith(prefix)) { - continue - } - const relative = path.slice(prefix.length) - if (!relative) { - continue - } - const [name, ...rest] = relative.split('/') - if (!name) { - continue - } - entries.set(name, { - name, - isDirectory: rest.length > 0, - isSymlink: false - }) - } - return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) - } - - async readFile(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { content: file.content, isBinary: false } - } - - async stat(filePath: string): Promise { - const statError = this.statErrors.get(normalize(filePath)) - if (statError) { - throw statError - } - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } - } - - writeFile = unsupported - writeFileBase64 = unsupported - writeFileBase64Chunk = unsupported - deletePath = unsupported - createFile = unsupported - createDir = unsupported - createDirNoClobber = unsupported - rename = unsupported - renameNoClobber = unsupported - copy = unsupported - realpath = async (path: string): Promise => path - search = unsupported - listFiles = unsupported - watch = unsupported -} - -async function unsupported(): Promise { - throw new Error('unsupported') -} - -function normalize(path: string): string { - return path.replace(/\\/g, '/').replace(/\/+$/, '') -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' describe('scanRemoteAiVaultSessions', () => { it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { diff --git a/src/main/ai-vault/session-delete-target.test.ts b/src/main/ai-vault/session-delete-target.test.ts new file mode 100644 index 00000000000..39c95f4f5f6 --- /dev/null +++ b/src/main/ai-vault/session-delete-target.test.ts @@ -0,0 +1,437 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { validateAiVaultSessionDeleteTarget } from './session-delete-target' + +// All roots are supplied via rootOptions so these tests never touch the real +// home directory or filesystem — validation is pure string-path judgement. +const HOME = join('/tmp', 'orca-ai-vault-delete-fixture-home') +const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') +const CURSOR_ROOT = join(HOME, '.cursor', 'projects') +const HERMES_ROOT = join(HOME, '.hermes', 'sessions') +const OPENCLAW_ROOT = join(HOME, '.openclaw') +const COPILOT_ROOT = join(HOME, '.copilot', 'session-state') +const DEVIN_ROOT = join(HOME, '.local', 'share', 'devin', 'cli', 'transcripts') +const PI_ROOT = join(HOME, '.pi', 'agent', 'sessions') +const OMP_ROOT = join(HOME, '.omp', 'agent', 'sessions') +const CLAUDE_ROOT = join(HOME, '.claude', 'projects') +const CLAUDE_SESSION_ENV_ROOT = join(HOME, '.claude', 'session-env') +const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') +const GROK_ROOT = join(HOME, '.grok', 'sessions') + +describe('validateAiVaultSessionDeleteTarget', () => { + it('allows a supported agent whose file resolves inside its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ + allowed: true, + agent: 'gemini', + resolvedPath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + removals: [ + { + path: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + kind: 'file', + roots: [GEMINI_ROOT] + } + ] + }) + }) + + it('allows gemini .jsonl files too, since discovery accepts both extensions', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects a path that escapes its root via ..', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, '..', '..', '..', 'etc', 'passwd.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'path-outside-known-roots' }) + }) + + it('rejects a path entirely outside the known roots', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(HOME, 'Documents', 'notes.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'path-outside-known-roots' }) + }) + + it('rejects a file whose extension the agent never discovers', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'agent-transcripts', 'session-1.txt'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'cursor', reason: 'undiscoverable-path' }) + }) + + it('rejects a cursor path missing the agent-transcripts segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'cursor', reason: 'undiscoverable-path' }) + }) + + it('allows a cursor path that does carry the agent-transcripts segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'agent-transcripts', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects a hermes file whose basename is missing the session_ prefix', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'hermes', + filePath: join(HERMES_ROOT, 'not-a-session.json'), + executionHostId: 'local', + rootOptions: { hermesSessionsDir: HERMES_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'hermes', reason: 'undiscoverable-path' }) + }) + + it('allows a hermes file whose basename carries the session_ prefix', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'hermes', + filePath: join(HERMES_ROOT, 'session_abc123.json'), + executionHostId: 'local', + rootOptions: { hermesSessionsDir: HERMES_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows an openclaw path under /agents/.../sessions/...', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(OPENCLAW_ROOT, 'agents', 'agent-1', 'sessions', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: OPENCLAW_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects an openclaw path missing the sessions segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(OPENCLAW_ROOT, 'agents', 'agent-1', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: OPENCLAW_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'openclaw', reason: 'undiscoverable-path' }) + }) + + it('rejects an agent whose own registry would keep a dangling entry (codex)', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'codex', + filePath: join(HOME, '.codex', 'sessions', 'rollout-1.jsonl'), + executionHostId: 'local' + }) + expect(result).toEqual({ allowed: false, agent: 'codex', reason: 'unsupported-agent' }) + }) + + it('rejects a non-local execution host', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + executionHostId: 'ssh:some-host', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'non-local-host' }) + }) + + // A real OpenCode SQLite-row session (the `#` identity) + // reaches the validator as agent 'opencode', which is stopped at the agent + // gate — so this is how such a session actually enters judgement. + it('rejects a real opencode session as an unsupported agent', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'opencode', + filePath: join(HOME, '.local', 'share', 'opencode', 'db.sqlite#session-1'), + executionHostId: 'local' + }) + expect(result).toEqual({ allowed: false, agent: 'opencode', reason: 'unsupported-agent' }) + }) + + // Defense-in-depth: even for a deletable agent, any '#'-bearing path is + // treated as a synthetic SQLite identity rather than a real file to delete. + it('rejects a deletable-agent path bearing a synthetic # marker', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'db.sqlite#session-1'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'synthetic-path' }) + }) + + it('rejects a blank filePath', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: ' ', + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'invalid-path' }) + }) + + it('allows a droid path under either of its .factory roots', () => { + const sessionsRoot = join(HOME, '.factory', 'sessions') + const projectsRoot = join(HOME, '.factory', 'projects') + const underSessions = validateAiVaultSessionDeleteTarget({ + agent: 'droid', + filePath: join(sessionsRoot, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { droidSessionsDir: sessionsRoot, droidProjectsDir: projectsRoot } + }) + expect(underSessions.allowed).toBe(true) + const underProjects = validateAiVaultSessionDeleteTarget({ + agent: 'droid', + filePath: join(projectsRoot, 'proj-a', 'session-2.jsonl'), + executionHostId: 'local', + rootOptions: { droidSessionsDir: sessionsRoot, droidProjectsDir: projectsRoot } + }) + expect(underProjects.allowed).toBe(true) + }) + + it('allows a copilot .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'copilot', + filePath: join(COPILOT_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { copilotSessionsDir: COPILOT_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a devin .json file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'devin', + filePath: join(DEVIN_ROOT, 'session-1.json'), + executionHostId: 'local', + rootOptions: { devinTranscriptsDir: DEVIN_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a pi .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'pi', + filePath: join(PI_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { piSessionsDir: PI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows an omp .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'omp', + filePath: join(OMP_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { ompSessionsDir: OMP_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + // Discovery matches extensions case-insensitively (walkSessionFiles folds via + // toLowerCase); the validator must accept the same uppercase-extension file. + it('allows an uppercase extension since discovery folds extension case', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.JSON'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a gemini file under a WSL-expanded root', () => { + const wslHome = join('/tmp', 'orca-ai-vault-delete-fixture-wsl-home') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(wslHome, '.gemini', 'tmp', 'project-a', 'session-1.json'), + executionHostId: 'local', + wslHomeDirs: [wslHome], + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + // A stateDir that already ends in `agents` must not be doubled to + // `agents/agents`; openClawAgentsRootDir uses it as-is. + it('allows an openclaw path whose stateDir already ends in agents', () => { + const agentsRoot = join(OPENCLAW_ROOT, 'agents') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(agentsRoot, 'agent-1', 'sessions', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: agentsRoot } + }) + expect(result.allowed).toBe(true) + }) +}) + +// The directory-shaped agents. Their delete unit is a path set derived from the +// one file discovery surfaced, not that file alone. +describe('directory-shaped agents', () => { + it('plans claude companions before the transcript, so a failure keeps the row', () => { + const filePath = join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath, + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: true, + agent: 'claude', + resolvedPath: filePath, + removals: [ + { + path: join(CLAUDE_ROOT, '-proj', 'sess-1'), + kind: 'directory', + roots: [CLAUDE_ROOT] + }, + { + path: join(CLAUDE_SESSION_ENV_ROOT, 'sess-1'), + kind: 'directory', + roots: [CLAUDE_SESSION_ENV_ROOT] + }, + { path: filePath, kind: 'file', roots: [CLAUDE_ROOT] } + ] + }) + }) + + it("never plans file-history, the rewind buffer for the user's own files", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result.allowed).toBe(true) + expect( + result.allowed && result.removals.some((removal) => removal.path.includes('file-history')) + ).toBe(false) + }) + + it("pairs a WSL-home claude session with that distro's session-env, not the local one", () => { + const wslHome = join('/tmp', 'orca-wsl-home') + const filePath = join(wslHome, '.claude', 'projects', '-proj', 'sess-2.jsonl') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath, + executionHostId: 'local', + wslHomeDirs: [wslHome], + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result.allowed && result.removals[1]).toEqual({ + path: join(wslHome, '.claude', 'session-env', 'sess-2'), + kind: 'directory', + roots: [join(wslHome, '.claude', 'session-env')] + }) + }) + + it('refuses a transcript whose stem would resolve to the project dir itself', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', '..jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: false, + agent: 'claude', + reason: 'no-session-directory' + }) + }) + + it('rejects a Task subagent transcript, which is only ever removed with its parent', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1', 'subagents', 'agent-abc.jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: false, + agent: 'claude', + reason: 'undiscoverable-path' + }) + }) + + it("removes rovo's session directory, not just the metadata.json inside it", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ + allowed: true, + agent: 'rovo', + resolvedPath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + removals: [{ path: join(ROVO_ROOT, 'sess-1'), kind: 'directory', roots: [ROVO_ROOT] }] + }) + }) + + it("removes grok's session directory under its encoded-cwd group", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'grok', + filePath: join(GROK_ROOT, '-Users-me-proj', 'sess-1', 'summary.json'), + executionHostId: 'local', + rootOptions: { grokSessionsDir: GROK_ROOT } + }) + + expect(result.allowed && result.removals).toEqual([ + { path: join(GROK_ROOT, '-Users-me-proj', 'sess-1'), kind: 'directory', roots: [GROK_ROOT] } + ]) + }) + + it("rejects grok's chat_history.jsonl — only summary.json names a session", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'grok', + filePath: join(GROK_ROOT, 'group', 'sess-1', 'chat_history.jsonl'), + executionHostId: 'local', + rootOptions: { grokSessionsDir: GROK_ROOT } + }) + + expect(result).toEqual({ allowed: false, agent: 'grok', reason: 'undiscoverable-path' }) + }) + + it('refuses a metadata.json sitting in the sessions root, which would trash every session', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ allowed: false, agent: 'rovo', reason: 'no-session-directory' }) + }) +}) diff --git a/src/main/ai-vault/session-delete-target.ts b/src/main/ai-vault/session-delete-target.ts new file mode 100644 index 00000000000..11ef38039dc --- /dev/null +++ b/src/main/ai-vault/session-delete-target.ts @@ -0,0 +1,145 @@ +import { basename, dirname, extname, join, resolve } from 'node:path' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { + isAiVaultDeletableAgent, + isAiVaultSyntheticSessionPath, + type AiVaultDeletableAgent, + type AiVaultSessionDeleteRejectionCode, + type AiVaultSessionDeleteRemoval, + type AiVaultSessionDeleteValidationResult +} from '../../shared/ai-vault-session-deletion' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { AI_VAULT_AGENT_SOURCES, isDiscoverableSessionFile } from './session-scanner-agent-sources' +import { subagentTranscriptsDirFor } from './session-scanner-subagent-transcripts' +import type { AiVaultScanOptions } from './session-scanner-types' + +// Agents whose session IS the directory holding the scanned file: everything +// beside it belongs to the same session (rovo's session_context.json, grok's +// chat_history.jsonl), so the directory is the only complete delete unit. +const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set(['rovo', 'grok']) + +export type ValidateAiVaultSessionDeleteTargetArgs = { + agent: AiVaultAgent + filePath: string + executionHostId: ExecutionHostId | null | undefined + // Passed in rather than fetched (getAiVaultWslHomeDirs) so this stays + // synchronous and pure. + wslHomeDirs?: readonly string[] + // Per-agent root overrides so tests never depend on the real home directory. + rootOptions?: AiVaultScanOptions +} + +// Pure path-only judgement: never touches the filesystem, and never throws — +// a malformed or hostile filePath rejects like any other invalid input. +// `allowed: true` alone is NOT enough to delete; see the caller contract on +// AiVaultSessionDeleteAllowedResult. +export function validateAiVaultSessionDeleteTarget( + args: ValidateAiVaultSessionDeleteTargetArgs +): AiVaultSessionDeleteValidationResult { + const { agent } = args + const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : '' + if (!filePath) { + return rejected(agent, 'invalid-path') + } + if (!isAiVaultDeletableAgent(agent)) { + return rejected(agent, 'unsupported-agent') + } + // Electron shell/fs APIs only act on this computer; an ssh/runtime session's + // path exists on the remote host. Same limit as Open/Reveal Log. + if (normalizeExecutionHostId(args.executionHostId) !== LOCAL_EXECUTION_HOST_ID) { + return rejected(agent, 'non-local-host') + } + if (isAiVaultSyntheticSessionPath(filePath)) { + return rejected(agent, 'synthetic-path') + } + + // resolve() collapses `..` first: isPathInsideOrEqual compares textually and + // would otherwise pass `/../../etc/x.jsonl`. + const resolvedPath = resolve(filePath) + const source = AI_VAULT_AGENT_SOURCES[agent] + const roots = source + .rootDirs(args.rootOptions ?? {}, args.wslHomeDirs ?? []) + // Why: OMP_CODING_AGENT_DIR='/' normalizes to '', which resolve()s to the + // process cwd — an empty root would silently allowlist it. + .filter((rootDir) => rootDir.trim().length > 0) + .map((rootDir) => resolve(rootDir)) + // Keep the root that actually contains this path: companion roots are derived + // from it, so a WSL-home session can't pair with the local host's companions. + const matchedRoot = roots.find((root) => isPathInsideOrEqual(root, resolvedPath)) + if (!matchedRoot) { + return rejected(agent, 'path-outside-known-roots') + } + // The scanner's own accept rule, so a path it would never have surfaced as a + // session row can't be accepted as a delete target either. + if (!isDiscoverableSessionFile(source, matchedRoot, resolvedPath)) { + return rejected(agent, 'undiscoverable-path') + } + + const removals = sessionDeleteRemovals({ agent, resolvedPath, matchedRoot, roots }) + if (!removals) { + return rejected(agent, 'no-session-directory') + } + + return { allowed: true, agent, resolvedPath, removals } +} + +// The ordered path set that removing this session means: companions first, the +// scanned path last. Null when the path names no session directory of its own, +// which would otherwise reach the root holding every session. +// +// `session-env//` is a companion — it holds that session's generated +// shell exports and nothing else. Its sibling `file-history//` is +// deliberately NOT: that is the rewind buffer holding earlier versions of the +// user's own files, and retiring a session is no reason to destroy the only +// copy that can restore them. +function sessionDeleteRemovals(args: { + agent: AiVaultDeletableAgent + resolvedPath: string + matchedRoot: string + roots: readonly string[] +}): readonly AiVaultSessionDeleteRemoval[] | null { + const { agent, resolvedPath, matchedRoot, roots } = args + + if (AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS.has(agent)) { + const sessionDir = dirname(resolvedPath) + if (sessionDir === matchedRoot || !isPathInsideOrEqual(matchedRoot, sessionDir)) { + return null + } + return [{ path: sessionDir, kind: 'directory', roots }] + } + + if (agent === 'claude') { + const sessionId = basename(resolvedPath, extname(resolvedPath)) + // A degenerate stem ('.' from `..jsonl`, or empty) would resolve the session + // dir to the project dir and trash every session in it. + if (!sessionId || sessionId === '.' || sessionId === '..') { + return null + } + // //, named after the transcript. Taking it rather than just the + // `subagents/` it holds is what stops an empty / being left behind. + // Derived from the scanner's subagents path so the two can't drift. + const sessionDir = dirname(subagentTranscriptsDirFor(resolvedPath)) + // Derived from the matched root, so a WSL-home session cleans up that + // distro's session-env rather than the local host's. + const sessionEnvRoot = join(dirname(matchedRoot), 'session-env') + return [ + { path: sessionDir, kind: 'directory', roots }, + { path: join(sessionEnvRoot, sessionId), kind: 'directory', roots: [sessionEnvRoot] }, + { path: resolvedPath, kind: 'file', roots } + ] + } + + return [{ path: resolvedPath, kind: 'file', roots }] +} + +function rejected( + agent: AiVaultAgent, + reason: AiVaultSessionDeleteRejectionCode +): AiVaultSessionDeleteValidationResult { + return { allowed: false, agent, reason } +} diff --git a/src/main/ai-vault/session-delete.test.ts b/src/main/ai-vault/session-delete.test.ts new file mode 100644 index 00000000000..eb25b11a820 --- /dev/null +++ b/src/main/ai-vault/session-delete.test.ts @@ -0,0 +1,293 @@ +import { join } from 'node:path' +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { lstatMock, realpathMock, trashItemMock, tryDeleteWslUncPathMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + realpathMock: vi.fn(), + trashItemMock: vi.fn(), + tryDeleteWslUncPathMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + realpath: realpathMock +})) + +vi.mock('electron', () => ({ + shell: { trashItem: trashItemMock } +})) + +vi.mock('../wsl-unc-delete', () => ({ + tryDeleteWslUncPath: tryDeleteWslUncPathMock +})) + +import { deleteAiVaultSessionFile } from './session-delete' + +const HOME = join('/tmp', 'orca-ai-vault-delete-exec-fixture-home') +const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') +const CLAUDE_ROOT = join(HOME, '.claude', 'projects') +const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') + +function enoent(): NodeJS.ErrnoException { + const error = new Error('not found') as NodeJS.ErrnoException + error.code = 'ENOENT' + return error +} + +function baseArgs(filePath: string) { + return { + agent: 'gemini' as const, + filePath, + executionHostId: 'local' as const, + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + } +} + +describe('deleteAiVaultSessionFile', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: not a WSL UNC path, so the fs-guard branch runs. + tryDeleteWslUncPathMock.mockResolvedValue(false) + }) + + it('trashes a regular file whose realpath matches the resolved path', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(filePath) + }) + + it('rejects a directory instead of trashing it', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => false }) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('rejects a symlink instead of trashing it (isFile() is false for a symlink under lstat)', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => false }) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('rejects a regular file whose realpath escapes the known roots', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + const escaped = join(HOME, 'Documents', 'escaped.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + // The file's realpath escapes; the root realpaths to itself (not symlinked). + realpathMock.mockImplementation((p: string) => Promise.resolve(p === filePath ? escaped : p)) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'path-outside-known-roots' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('accepts a session under a symlinked root by realpath-resolving the roots too', async () => { + // GEMINI_ROOT is a symlink to a real target; the file's realpath lands under + // the real target, which the text-only root would not match. Realpath-ing + // the root as well keeps this legit delete from a false rejection. + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + const realRoot = join('/real', '.gemini', 'tmp') + const realFile = join(realRoot, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockImplementation((p: string) => + Promise.resolve(p === GEMINI_ROOT ? realRoot : p === filePath ? realFile : p) + ) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(filePath) + }) + + it('treats ENOENT from lstat as an idempotent success', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockRejectedValue(enoent()) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('treats ENOENT from trashItem as an idempotent success (race with an external delete)', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockRejectedValue(enoent()) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + }) + + it('returns a failure result when trashItem throws a non-ENOENT error', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockRejectedValue(new Error('permission denied')) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'failed', agent: 'gemini', error: 'permission denied' }) + }) + + it('short-circuits a rejected validation (unsupported agent) before touching the filesystem', async () => { + const filePath = join(HOME, '.codex', 'sessions', 'rollout-1.jsonl') + + const result = await deleteAiVaultSessionFile({ + agent: 'codex', + filePath, + executionHostId: 'local' + }) + + expect(result).toEqual({ outcome: 'rejected', agent: 'codex', reason: 'unsupported-agent' }) + expect(lstatMock).not.toHaveBeenCalled() + expect(realpathMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + expect(tryDeleteWslUncPathMock).not.toHaveBeenCalled() + }) + + it('delegates a WSL UNC file to tryDeleteWslUncPath (non-recursive) and never calls trashItem', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + tryDeleteWslUncPathMock.mockResolvedValue(true) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(filePath, { recursive: false }) + expect(lstatMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('deletes a WSL UNC directory-shaped session recursively, never via trashItem', async () => { + // The bug this guards: a directory removal (rovo/grok/claude dir) on a WSL + // UNC path used to skip the WSL branch and hit shell.trashItem, which + // cannot trash a WSL volume item — so it failed on Windows or was silently + // stranded by the 9P filesystem's unreliable lstat. + tryDeleteWslUncPathMock.mockResolvedValue(true) + + const result = await deleteAiVaultSessionFile({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(join(ROVO_ROOT, 'sess-1'), { + recursive: true + }) + expect(lstatMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + }) + + // A session whose delete unit is a path set. What matters here is the + // order — the transcript is what puts the row on screen, so it must be the + // last thing removed. + describe('directory-shaped agents', () => { + const claudeArgs = { + agent: 'claude' as const, + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl'), + executionHostId: 'local' as const, + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + } + + it('trashes the subagents and session-env dirs before the transcript', async () => { + lstatMock.mockImplementation((path: string) => + Promise.resolve( + path.endsWith('.jsonl') ? { isFile: () => true } : { isDirectory: () => true } + ) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock.mock.calls.map(([path]) => path)).toEqual([ + join(CLAUDE_ROOT, '-proj', 'sess-1'), + join(HOME, '.claude', 'session-env', 'sess-1'), + claudeArgs.filePath + ]) + }) + + it('still deletes when a companion is absent — most sessions spawn no subagent', async () => { + lstatMock.mockImplementation((path: string) => + path.endsWith('.jsonl') ? Promise.resolve({ isFile: () => true }) : Promise.reject(enoent()) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock.mock.calls.map(([path]) => path)).toEqual([claudeArgs.filePath]) + }) + + it('leaves the transcript in place when a companion removal is rejected', async () => { + // A file where the plan expects the session directory: refuse rather + // than coerce, and stop before the transcript so the row stays to retry. + lstatMock.mockImplementation((path: string) => + Promise.resolve( + path === join(CLAUDE_ROOT, '-proj', 'sess-1') + ? { isFile: () => true, isDirectory: () => false } + : { isFile: () => true, isDirectory: () => true } + ) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'claude', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it("trashes rovo's session directory rather than the metadata file", async () => { + const sessionDir = join(ROVO_ROOT, 'sess-1') + lstatMock.mockResolvedValue({ isDirectory: () => true }) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile({ + agent: 'rovo', + filePath: join(sessionDir, 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(sessionDir) + }) + }) +}) diff --git a/src/main/ai-vault/session-delete.ts b/src/main/ai-vault/session-delete.ts new file mode 100644 index 00000000000..d52725ff0fb --- /dev/null +++ b/src/main/ai-vault/session-delete.ts @@ -0,0 +1,104 @@ +import { lstat, realpath } from 'node:fs/promises' +import { shell } from 'electron' +import type { + AiVaultDeleteSessionResult, + AiVaultSessionDeleteRemoval +} from '../../shared/ai-vault-session-deletion' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { + validateAiVaultSessionDeleteTarget, + type ValidateAiVaultSessionDeleteTargetArgs +} from './session-delete-target' +import { tryDeleteWslUncPath } from '../wsl-unc-delete' +import { isENOENT } from '../ipc/filesystem-auth' + +// Trashes a validated session's paths, companions first so a part-way failure +// leaves the row on screen to retry from instead of stranding the rest on disk. +// Never throws: IPC payloads are untyped at runtime, so a bad input and an fs +// error both resolve to a discriminated result the handler can render. +export async function deleteAiVaultSessionFile( + args: ValidateAiVaultSessionDeleteTargetArgs +): Promise { + const validation = validateAiVaultSessionDeleteTarget(args) + if (!validation.allowed) { + return { outcome: 'rejected', agent: validation.agent, reason: validation.reason } + } + const { agent, removals } = validation + + try { + for (const removal of removals) { + const rejection = await removeOne(removal) + if (rejection) { + return { outcome: 'rejected', agent, reason: rejection } + } + } + return { outcome: 'deleted' } + } catch (error) { + return { + outcome: 'failed', + agent, + error: error instanceof Error ? error.message : String(error) + } + } +} + +// Trash one planned path, or return the rejection code that stops the plan. A +// missing path is success everywhere: companions are optional (no subagents dir +// if none ever ran) and an externally-deleted transcript must stay idempotent. +async function removeOne( + removal: AiVaultSessionDeleteRemoval +): Promise<'unexpected-target-kind' | 'path-outside-known-roots' | null> { + // First, because a WSL UNC path defeats both guards below: 9P's stat is + // unreliable and the volume has no Recycle Bin for trashItem. Its own + // `rm -f`/`-rf` is idempotent and never follows a symlink. + if (await tryDeleteWslUncPath(removal.path, { recursive: removal.kind === 'directory' })) { + return null + } + + let stats + try { + stats = await lstat(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + // lstat (not stat) so a symlink is caught here rather than dereferenced. + const kindMatches = removal.kind === 'file' ? stats.isFile() : stats.isDirectory() + if (!kindMatches) { + return 'unexpected-target-kind' + } + + let realResolvedPath: string + try { + realResolvedPath = await realpath(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + // Catches a symlinked parent that escapes the agent's roots, which lstat on + // the leaf can't see. Roots are only resolve()'d, so realpath them too or a + // session under a symlinked root (~/.claude -> /Volumes/data/.claude) is + // falsely rejected; a root absent on this host falls back to its text form. + if (realResolvedPath !== removal.path) { + const realRoots = await Promise.all( + removal.roots.map((root) => realpath(root).catch(() => root)) + ) + if (!realRoots.some((root) => isPathInsideOrEqual(root, realResolvedPath))) { + return 'path-outside-known-roots' + } + } + + try { + await shell.trashItem(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + return null +} diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts index 9cdc22a8fb4..af1a5e34ec2 100644 --- a/src/main/ai-vault/session-list-result-validation.test.ts +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -19,6 +19,78 @@ describe('parseAiVaultListResult', () => { ) }) + it('keeps known-agent sessions while silently dropping unknown-agent rows', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession(), { ...validSession('session-new'), agent: 'future-agent' }], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]?.agent).toBe('codex') + expect(parsed.sessions[0]?.sessionId).toBe('session-1') + expect(parsed.issues).toEqual([]) + }) + + it('accepts an all-unknown-agent response as an empty supported session list', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { ...validSession('future-1'), agent: 'future-agent' }, + { ...validSession('future-2'), agent: 'newer-agent' } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toEqual([]) + expect(parsed.issues).toEqual([]) + }) + + it('keeps known scan issues while silently dropping unknown-agent issues', () => { + const parsed = parseAiVaultListResult({ + sessions: [], + issues: [validScanIssue(), validScanIssue('future-agent')], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([validScanIssue()]) + }) + + it('reports malformed scan issues without counting unknown-agent issues as invalid', () => { + const parsed = parseAiVaultListResult({ + sessions: [], + issues: [validScanIssue(), { agent: 'future-agent' }, validScanIssue('newer-agent')], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([ + validScanIssue(), + expect.objectContaining({ + path: 'aiVault.listSessions', + message: expect.stringContaining('Skipped 1 invalid') + }) + ]) + }) + + it('does not throw when malformed rows are mixed with well-formed unknown sessions', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { ...validSession('malformed-future'), agent: 'future-agent', messageCount: 'invalid' }, + { ...validSession('valid-future'), agent: 'newer-agent' } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toEqual([]) + expect(parsed.issues).toContainEqual( + expect.objectContaining({ + path: 'aiVault.listSessions', + message: expect.stringContaining('Skipped 1 invalid') + }) + ) + }) + it('preserves the optional session fields the panel renders', () => { const parsed = parseAiVaultListResult({ sessions: [ @@ -55,18 +127,18 @@ describe('parseAiVaultListResult', () => { }) }) -function validSession(): Record { +function validSession(sessionId = 'session-1'): Record { return { - id: 'local:codex:session-1:/tmp/session-1.jsonl', + id: `local:codex:${sessionId}:/tmp/${sessionId}.jsonl`, executionHostId: 'local', executionHostPlatform: 'linux', agent: 'codex', - sessionId: 'session-1', + sessionId, title: 'Session one', cwd: '/repo', branch: null, model: null, - filePath: '/tmp/session-1.jsonl', + filePath: `/tmp/${sessionId}.jsonl`, codexHome: null, createdAt: null, updatedAt: null, @@ -76,7 +148,15 @@ function validSession(): Record { previewMessages: [], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: 'codex resume session-1', + resumeCommand: `codex resume ${sessionId}`, subagent: null } } + +function validScanIssue(agent = 'codex'): Record { + return { + agent, + path: '/tmp/sessions', + message: 'permission denied' + } +} diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 6d69a06af45..abcdd1612b3 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -1,7 +1,19 @@ import { z } from 'zod' -import { AI_VAULT_AGENTS, type AiVaultListResult } from '../../shared/ai-vault-types' +import { + AI_VAULT_AGENTS, + type AiVaultAgent, + type AiVaultListResult, + type AiVaultScanIssue, + type AiVaultSession +} from '../../shared/ai-vault-types' import { normalizeExecutionHostId } from '../../shared/execution-host' +const aiVaultAgentSet = new Set(AI_VAULT_AGENTS) + +function isAiVaultAgent(agent: string): agent is AiVaultAgent { + return aiVaultAgentSet.has(agent) +} + const nodePlatformSchema = z.enum([ 'aix', 'android', @@ -35,7 +47,7 @@ const aiVaultSessionSchema = z.object({ id: z.string(), executionHostId: executionHostIdSchema, executionHostPlatform: nodePlatformSchema.nullable().optional(), - agent: z.enum(AI_VAULT_AGENTS), + agent: z.string().min(1), sessionId: z.string(), title: z.string(), cwd: z.string().nullable(), @@ -67,7 +79,7 @@ const aiVaultSessionSchema = z.object({ const aiVaultScanIssueSchema = z.object({ executionHostId: executionHostIdSchema.optional(), - agent: z.enum(AI_VAULT_AGENTS), + agent: z.string().min(1), kind: z.enum(['host', 'scope', 'notice']).optional(), path: z.string(), message: z.string() @@ -84,19 +96,42 @@ export function parseAiVaultListResult(value: unknown): AiVaultListResult { if (!envelope.success) { throw new Error(envelope.error.issues[0]?.message ?? 'unexpected result shape') } - const sessions = envelope.data.sessions.flatMap((session) => { + const sessions: AiVaultSession[] = [] + let malformedSessionCount = 0 + let wellFormedSessionCount = 0 + for (const session of envelope.data.sessions) { const parsed = aiVaultSessionSchema.safeParse(session) - return parsed.success ? [parsed.data] : [] - }) - if (envelope.data.sessions.length > 0 && sessions.length === 0) { + if (!parsed.success) { + malformedSessionCount += 1 + continue + } + wellFormedSessionCount += 1 + if (!isAiVaultAgent(parsed.data.agent)) { + continue + } + sessions.push({ ...parsed.data, agent: parsed.data.agent }) + } + if ( + envelope.data.sessions.length > 0 && + malformedSessionCount > 0 && + wellFormedSessionCount === 0 + ) { throw new Error('all supplied Agent Session History sessions were invalid') } - const issues = envelope.data.issues.flatMap((issue) => { + const issues: AiVaultScanIssue[] = [] + let malformedIssueCount = 0 + for (const issue of envelope.data.issues) { const parsed = aiVaultScanIssueSchema.safeParse(issue) - return parsed.success ? [parsed.data] : [] - }) - const invalidCount = - envelope.data.sessions.length - sessions.length + (envelope.data.issues.length - issues.length) + if (!parsed.success) { + malformedIssueCount += 1 + continue + } + if (!isAiVaultAgent(parsed.data.agent)) { + continue + } + issues.push({ ...parsed.data, agent: parsed.data.agent }) + } + const invalidCount = malformedSessionCount + malformedIssueCount if (invalidCount > 0) { issues.push({ agent: 'codex', diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts new file mode 100644 index 00000000000..33320498b44 --- /dev/null +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -0,0 +1,254 @@ +import { homedir } from 'node:os' +import { basename, dirname, extname, join, relative } from 'node:path' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion' +import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' +import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' +import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' +import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' +import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots' +import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' +import type { AiVaultScanOptions } from './session-scanner-types' +import { normalizeAgentSessionsDir } from './session-scanner-values' + +export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') +const CODEX_SESSIONS_DIR = join( + process.env.CODEX_HOME?.trim() || DEFAULT_CODEX_HOME_DIR, + 'sessions' +) +const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp') +const COPILOT_SESSIONS_DIR = join( + process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'), + 'session-state' +) +const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') +const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') +const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') +const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') +const PI_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), + '.pi' +) +// Why: Devin ATIF transcripts are stored under /transcripts. +const DEVIN_TRANSCRIPTS_DIR = join( + process.env.DEVIN_HOME?.trim() || join(homedir(), '.local', 'share', 'devin', 'cli'), + 'transcripts' +) +const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') +const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') + +/** + * Where one agent's session files live and which of them count as sessions. + * + * The scanner walks these roots to list sessions; the delete validator accepts + * only paths this same shape would have surfaced. Both read this one table, so + * "deletable" and "discoverable" cannot drift apart. + */ +export type AiVaultAgentSource = { + // A function, not a value: grok resolves its root lazily because a + // module-scope call binds across chunks at init time and breaks on bundle + // ordering. Returns the local host root plus one per WSL distro home. + rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[] + extensions: readonly string[] + filePredicate?: (filePath: string) => boolean + // Return false to skip a directory; depth 0 is a child of the root. + directoryPredicate?: (name: string, depth: number) => boolean + // Roots that are alternates for one install rather than distinct locations, + // so the per-agent limit applies across them rather than to each. + mergeRootDiscoveries?: boolean +} + +// Every deletable agent needs an entry — the delete validator derives its roots +// and its accept rule from this table alone, so adding an agent to +// AI_VAULT_DELETABLE_AGENTS without a source here is a type error. +type AiVaultAgentSourceTable = Record & + Partial> + +// Agents absent from this table are discovered by shape-specific scanners +// instead: opencode (SQLite plus legacy files) and antigravity (brain dirs). +export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { + claude: { + rootDirs: (options, wslHomeDirs) => + claudeProjectsRootDirs({ claudeProjectsDir: options.claudeProjectsDir, wslHomeDirs }), + extensions: ['.jsonl'], + // Why: Task subagent transcripts under `/subagents/` share the parent + // sessionId and aren't independently resumable, so they'd just duplicate the + // parent as untitled rows; prune the subtree and read them on demand under + // their parent instead. + directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + }, + codex: { + rootDirs: (options, wslHomeDirs) => + uniqueCodexSessionsDirs([ + options.codexSessionsDir ?? CODEX_SESSIONS_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.codex', 'sessions')), + // Why: Orca-launched WSL Codex sessions use an Orca-owned CODEX_HOME, + // not the user's default ~/.codex history root. + ...wslHomeDirs.map((homeDir) => + join(homeDir, '.local', 'share', 'orca', 'codex-runtime-home', 'home', 'sessions') + ), + ...(options.additionalCodexSessionsDirs ?? []) + ]), + extensions: ['.jsonl'] + }, + gemini: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ + '.gemini', + 'tmp' + ]), + extensions: ['.json', '.jsonl'] + }, + copilot: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.copilotSessionsDir ?? COPILOT_SESSIONS_DIR, wslHomeDirs, [ + '.copilot', + 'session-state' + ]), + extensions: ['.jsonl'] + }, + cursor: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR, wslHomeDirs, [ + '.cursor', + 'projects' + ]), + extensions: ['.jsonl'], + filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts') + }, + grok: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.grokSessionsDir ?? resolveGrokSessionsDir(), wslHomeDirs, [ + '.grok', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath) === 'summary.json' + }, + devin: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [ + '.local', + 'share', + 'devin', + 'cli', + 'transcripts' + ]), + extensions: ['.json'] + }, + hermes: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, wslHomeDirs, [ + '.hermes', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath).startsWith('session_') + }, + rovo: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.rovoSessionsDir ?? ROVO_SESSIONS_DIR, wslHomeDirs, [ + '.rovodev', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath) === 'metadata.json' + }, + pi: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.piSessionsDir ?? PI_SESSIONS_DIR, wslHomeDirs, [ + '.pi', + 'agent', + 'sessions' + ]), + extensions: ['.jsonl'] + }, + omp: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ + '.omp', + 'agent', + 'sessions' + ]), + extensions: ['.jsonl'], + // Why: task subagent transcripts live inside the session's same-named + // artifact directory (`_/`); surfaced as top-level rows they + // drown coordinators under their own workers (#9330). Prune the subtree + // and read them on demand under their parent instead. Unlike Claude's, + // these carry their own sessionId and would resume by path — but OMP's + // own picker only globs `*/*.jsonl`, so it never offers them either. + // Depth 0 is the workspace dir, which is never an artifact dir. + directoryPredicate: (name, depth) => depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name) + }, + openclaw: { + // Sessions live under /agents; a stateDir already ending in + // `agents` is used as-is. The current and legacy state dirs are the same + // install, so their discoveries merge. + rootDirs: (options, wslHomeDirs) => + [ + options.openclawStateDir ?? OPENCLAW_STATE_DIR, + options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot'), + ...wslHomeDirs.map((homeDir) => join(homeDir, '.openclaw')), + ...wslHomeDirs.map((homeDir) => join(homeDir, '.clawdbot')) + ].map((stateDir) => (basename(stateDir) === 'agents' ? stateDir : join(stateDir, 'agents'))), + extensions: ['.jsonl'], + filePredicate: (filePath) => pathSegments(filePath).includes('sessions'), + mergeRootDiscoveries: true + }, + droid: { + rootDirs: (options, wslHomeDirs) => [ + ...sessionRootDirs(options.droidSessionsDir ?? DROID_SESSIONS_DIR, wslHomeDirs, [ + '.factory', + 'sessions' + ]), + ...sessionRootDirs(options.droidProjectsDir ?? DROID_PROJECTS_DIR, wslHomeDirs, [ + '.factory', + 'projects' + ]) + ], + extensions: ['.jsonl'] + }, + kimi: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ + '.kimi-code', + 'sessions' + ]), + extensions: ['.json'], + // Why: each Kimi session is /wd_*/session_*/state.json; match + // only those (not the sibling agents/*/wire.jsonl transcripts). + filePredicate: (filePath) => + basename(filePath) === 'state.json' && basename(dirname(filePath)).startsWith('session_') + } +} + +/** + * Whether a scan rooted at `rootDir` would surface `filePath`: its extension, + * its file predicate, and every directory between the two passing the directory + * predicate. This is the delete validator's accept rule, so a path no scan + * would ever list can't become a delete target either. + */ +export function isDiscoverableSessionFile( + source: AiVaultAgentSource, + rootDir: string, + filePath: string +): boolean { + if (!source.extensions.includes(extname(filePath).toLowerCase())) { + return false + } + if (source.filePredicate && !source.filePredicate(filePath)) { + return false + } + const { directoryPredicate } = source + if (!directoryPredicate) { + return true + } + // Indexed like walkSessionFiles: depth 0 is a child of rootDir. + return pathSegments(relative(rootDir, dirname(filePath))) + .filter(Boolean) + .every((name, depth) => directoryPredicate(name, depth)) +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]/) +} diff --git a/src/main/ai-vault/session-scanner-codex-cached-title.ts b/src/main/ai-vault/session-scanner-codex-cached-title.ts new file mode 100644 index 00000000000..f356e30b890 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-cached-title.ts @@ -0,0 +1,15 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { SessionFileCandidate } from './session-scanner-types' +import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' + +export async function refreshCachedCodexTitle( + candidate: SessionFileCandidate, + session: AiVaultSession +): Promise { + const title = await readCodexSessionIndexTitle( + candidate.file.path, + candidate.codexHome, + session.sessionId + ) + return title && title !== session.title ? { ...session, title } : session +} diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index d93d6f01fea..39398fd4d09 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,5 +1,5 @@ import { readdir, stat } from 'node:fs/promises' -import { basename, delimiter, extname, join } from 'node:path' +import { extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -42,30 +42,6 @@ export async function discoverFiles(args: { } } -export async function discoverOpenClawFiles(args: { - rootDirs: string[] - limit: number - issues: AiVaultScanIssue[] -}): Promise { - const discoveries = await Promise.all( - args.rootDirs.map((rootDir) => - discoverFiles({ - rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'), - limit: args.limit, - agent: 'openclaw', - issues: args.issues, - extensions: ['.jsonl'], - filePredicate: (path) => path.split(/[\\/]/).includes('sessions') - }) - ) - ) - const files = discoveries - .flatMap((discovery) => discovery.files) - .sort((left, right) => right.mtimeMs - left.mtimeMs) - .slice(0, args.limit) - return { agent: 'openclaw', rootDir: args.rootDirs.join(delimiter), files } -} - export async function walkSessionFiles( dirPath: string, agent: AiVaultAgent, diff --git a/src/main/ai-vault/session-scanner-droid-kimi-sources.ts b/src/main/ai-vault/session-scanner-droid-kimi-sources.ts deleted file mode 100644 index 57ad71f0681..00000000000 --- a/src/main/ai-vault/session-scanner-droid-kimi-sources.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { homedir } from 'node:os' -import { basename, dirname, join } from 'node:path' -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { discoverFiles } from './session-scanner-discovery' -import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' -import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' - -const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') - -export function droidDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return [ - ...sessionRootDirs(options.droidSessionsDir ?? DROID_SESSIONS_DIR, wslHomeDirs, [ - '.factory', - 'sessions' - ]), - ...sessionRootDirs( - options.droidProjectsDir ?? join(homedir(), '.factory', 'projects'), - wslHomeDirs, - ['.factory', 'projects'] - ) - ].map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'droid', issues, extensions: ['.jsonl'] }) - ) -} - -export function kimiDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ - '.kimi-code', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'kimi', - issues, - extensions: ['.json'], - // Why: each Kimi session is /wd_*/session_*/state.json; match - // only those (not the sibling agents/*/wire.jsonl transcripts). - filePredicate: (path) => - basename(path) === 'state.json' && basename(dirname(path)).startsWith('session_') - }) - ) -} - -function sessionRootDirs( - hostRootDir: string, - wslHomeDirs: readonly string[], - segments: readonly string[] -): string[] { - return [hostRootDir, ...wslHomeDirs.map((homeDir) => join(homeDir, ...segments))] -} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index b4d0c171171..8aeb89089fc 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -5,6 +5,7 @@ import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' +import { withOmpSubagentTranscriptCount } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime, ResumableSessionParseState, @@ -239,10 +240,14 @@ export function createMessageGraphSessionResumeState( agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { - return accumulatorFoldResumeState( + const state = accumulatorFoldResumeState( createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), consumeMessageGraphRecordLine ) + // Why: only OMP materializes task-subagent transcripts beside its sessions + // (in the same-named artifact dir); the row UI shows the count without + // expanding details. Pi/OpenClaw have no such layout — skip the readdir. + return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state } async function parseMessageGraphSessionLines(args: { diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts new file mode 100644 index 00000000000..16511e14239 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts @@ -0,0 +1,81 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' +const PARENT_SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function childTranscript(id: string, timestamp: string, prompt: string): string { + return [ + JSON.stringify({ type: 'session', version: 3, id, cwd: '/repo/app', timestamp }), + JSON.stringify({ type: 'message', timestamp, message: { role: 'user', content: prompt } }) + ].join('\n') +} + +describe('listOmpSubagentSessions', () => { + it('lists artifact-dir transcripts under the parent, titled by task label', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-subagent-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const artifactDir = join(workspace, SESSION_STEM) + await mkdir(join(artifactDir, 'local'), { recursive: true }) + await writeFile(parentPath, '') + await writeFile( + join(artifactDir, 'AuthAndPreflight.jsonl'), + childTranscript( + 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + '2026-05-01T10:01:00.000Z', + 'Map the auth surface' + ) + ) + await writeFile( + join(artifactDir, 'BitbucketDcApi.jsonl'), + childTranscript( + 'aaaaaaaa-bbbb-4ccc-8ddd-333333333333', + '2026-05-01T10:02:00.000Z', + 'Read the DC API spec' + ) + ) + // Artifacts are not transcripts; nested files belong to their own parents. + await writeFile(join(artifactDir, 'notes.md'), 'not a transcript') + await writeFile(join(artifactDir, 'local', 'plan.jsonl'), '{}') + + const result = await listOmpSubagentSessions({ + parentFilePath: parentPath, + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + // Newest first, titled by the coordinator-given task label, each linked to + // the parent derived from the layout (not the child's own session record). + expect( + result.sessions.map((session) => ({ + title: session.title, + parent: session.subagent?.parentSessionId + })) + ).toEqual([ + { title: 'BitbucketDcApi', parent: PARENT_SESSION_ID }, + { title: 'AuthAndPreflight', parent: PARENT_SESSION_ID } + ]) + }) + + it('resolves empty for a session that never delegated', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-subagent-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + await writeFile(parentPath, '') + + await expect( + listOmpSubagentSessions({ parentFilePath: parentPath, platform: 'darwin' }) + ).resolves.toEqual({ sessions: [], issues: [] }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts new file mode 100644 index 00000000000..0efcdbdc92c --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts @@ -0,0 +1,113 @@ +import { readdir, stat } from 'node:fs/promises' +import { basename, extname, join } from 'node:path' +import type { + AiVaultScanIssue, + AiVaultSession, + AiVaultSubagentListResult +} from '../../shared/ai-vault-types' +import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' +import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers' +import { + isOmpSubagentTranscriptFileName, + ompArtifactDirFor +} from './session-scanner-omp-subagent-transcripts' +import { errorMessage } from './session-scanner-values' + +// Match the Claude subagent lister's deliberate parse batching: opening every +// read stream at once stalls over WSL UNC paths. +const OMP_SUBAGENT_PARSE_CONCURRENCY = 8 + +/** + * List the task-subagent transcripts of one OMP session, on demand. The main + * scan prunes session artifact directories for speed, so this is the only path + * that reads them — and only when the user expands a session's details. + * (Lives apart from session-scanner-omp-subagent-transcripts.ts so the graph + * parser can import the count decorator without a parser↔lister import cycle — + * the same split as Claude's transcripts/lister pair.) + */ +export async function listOmpSubagentSessions(args: { + parentFilePath: string + platform?: NodeJS.Platform +}): Promise { + const platform = args.platform ?? process.platform + const issues: AiVaultScanIssue[] = [] + const artifactDir = ompArtifactDirFor(args.parentFilePath) + + let entries + try { + entries = await readdir(artifactDir, { withFileTypes: true }) + } catch { + return { sessions: [], issues } + } + + const transcriptNames = entries + .filter((entry) => isOmpSubagentTranscriptFileName(entry.name, entry.isFile())) + .map((entry) => entry.name) + if (transcriptNames.length === 0) { + return { sessions: [], issues } + } + + // Why: the layout fixes the parent's sessionId (_.jsonl → the + // artifact dir); deriving it here keeps a child linking to its parent even + // when the child transcript carries its own distinct session record. + const parentSessionId = sessionIdFromFileName(args.parentFilePath) + const parsed: (AiVaultSession | null)[] = [] + for (let index = 0; index < transcriptNames.length; index += OMP_SUBAGENT_PARSE_CONCURRENCY) { + const batch = transcriptNames.slice(index, index + OMP_SUBAGENT_PARSE_CONCURRENCY) + const batchResults = await Promise.all( + batch.map((name) => + parseOmpSubagentTranscript({ artifactDir, name, parentSessionId, platform, issues }) + ) + ) + parsed.push(...batchResults) + } + + return { + sessions: parsed + .filter((session): session is AiVaultSession => session !== null) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)), + issues + } +} + +async function parseOmpSubagentTranscript(args: { + artifactDir: string + name: string + parentSessionId: string + platform: NodeJS.Platform + issues: AiVaultScanIssue[] +}): Promise { + const filePath = join(args.artifactDir, args.name) + try { + const fileStat = await stat(filePath) + // The shared OMP parser decorates every parse with an artifact-dir count, so + // a child row carries its own grandchild count. It is accurate but has no + // renderer — subagent rows don't expand — and this lister is local-only, so + // the remote partition never reaches it. + const session = await parseMessageGraphSessionFile( + 'omp', + { path: filePath, mtimeMs: fileStat.mtimeMs, modifiedAt: fileStat.mtime.toISOString() }, + args.platform + ) + if (!session) { + return null + } + return { + ...session, + // Why: OMP names each child transcript after the task's label — the name + // the coordinator gave it. That beats the transcript-derived fallback + // (the raw task prompt). + title: basename(args.name, extname(args.name)), + subagent: { + parentSessionId: args.parentSessionId, + agentType: null, + // OMP parents don't record per-child terminal statuses in a shape this + // lister can attribute; unknown beats a stale guess. + status: null + } + } + } catch (err) { + args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) }) + return null + } +} diff --git a/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts new file mode 100644 index 00000000000..7a8314962b7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts @@ -0,0 +1,149 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' +const SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions OMP subagent pruning', () => { + it('prunes artifact-dir transcripts instead of listing them as sessions', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-prune-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspaceDir = join(roots.ompSessionsDir, 'home-app-85dfa2f0') + const artifactDir = join(workspaceDir, SESSION_STEM) + await mkdir(artifactDir, { recursive: true }) + + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Delegate some tasks' } + } + ]) + ) + await writeFile( + join(artifactDir, 'AuthAndPreflight.jsonl'), + jsonLines([ + { + type: 'session', + version: 3, + id: 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + cwd: '/repo/app', + timestamp: '2026-05-01T10:01:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:01:01.000Z', + message: { role: 'user', content: 'Subagent task prompt' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + // Only the coordinator surfaces; the task child is not a row, but it is + // still counted for the row's "N subagents" affordance (#9330). + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: SESSION_ID, + subagentTranscriptCount: 1 + }) + }) + + it('still scans a workspace dir whose name looks like a session stem', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-depth-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + // Depth 0 is the workspace dir. Pruning by name alone would hide every + // session in a workspace that happened to be named like a session stem. + const workspaceDir = join(roots.ompSessionsDir, SESSION_STEM) + await mkdir(workspaceDir, { recursive: true }) + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Hello from a stem-named workspace' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions.map((entry) => entry.sessionId)).toContain(SESSION_ID) + }) + + it('picks up a task transcript written after the coordinator last changed', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-late-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspaceDir = join(roots.ompSessionsDir, 'home-app-85dfa2f0') + await mkdir(workspaceDir, { recursive: true }) + // A coordinator that persisted no conversation turns: its file never + // changes again, so only the cached-reuse recount can see a late child. + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + } + ]) + ) + + const first = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(first.sessions.find((entry) => entry.sessionId === SESSION_ID)).toMatchObject({ + messageCount: 0, + subagentTranscriptCount: 0 + }) + + await mkdir(join(workspaceDir, SESSION_STEM), { recursive: true }) + await writeFile( + join(workspaceDir, SESSION_STEM, 'AuthAndPreflight.jsonl'), + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:01:01.000Z', + message: { role: 'user', content: 'Late task prompt' } + } + ]) + ) + + const second = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect( + second.sessions.find((entry) => entry.sessionId === SESSION_ID)?.subagentTranscriptCount + ).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts new file mode 100644 index 00000000000..b8c802a021f --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + OMP_SESSION_ARTIFACT_DIR_PATTERN, + countOmpSubagentTranscripts, + partitionOmpSubagentTranscriptPaths +} from './session-scanner-omp-subagent-transcripts' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeWorkspaceDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-omp-subagents-')) + tempRoots.push(root) + return root +} + +describe('OMP_SESSION_ARTIFACT_DIR_PATTERN', () => { + it('matches session artifact dir names and nothing else in the layout', () => { + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(SESSION_STEM)).toBe(true) + // Task children are label-named. + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('AuthAndPreflight')).toBe(false) + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('local')).toBe(false) + // Workspace dir names OMP encodes from a cwd (relative to home, relative to + // tmp, absolute, or slug-hashed) never collide with a session stem. The + // local prune doesn't depend on this — it skips depth 0 — but the remote + // partition has no root to measure depth against, so it does. + for (const workspaceDirName of [ + `-${SESSION_STEM}`, + `-tmp-${SESSION_STEM}`, + `--${SESSION_STEM}--`, + 'home-app-85dfa2f063812c58976deb581167a634a4' + ]) { + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(workspaceDirName)).toBe(false) + } + }) +}) + +describe('countOmpSubagentTranscripts', () => { + it('counts only direct-child transcripts of the artifact dir', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const artifactDir = join(workspace, SESSION_STEM) + await mkdir(join(artifactDir, 'local'), { recursive: true }) + await writeFile(parentPath, '') + await writeFile(join(artifactDir, 'AuthAndPreflight.jsonl'), '') + await writeFile(join(artifactDir, 'BitbucketDcApi.jsonl'), '') + // Artifacts are not transcripts; nested files belong to their own parents. + await writeFile(join(artifactDir, 'notes.md'), '') + await writeFile(join(artifactDir, 'local', 'plan.jsonl'), '') + + await expect(countOmpSubagentTranscripts(parentPath)).resolves.toBe(2) + }) + + it('returns 0 when the session never delegated (no artifact dir)', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + await writeFile(parentPath, '') + + await expect(countOmpSubagentTranscripts(parentPath)).resolves.toBe(0) + }) +}) + +describe('partitionOmpSubagentTranscriptPaths', () => { + it('drops artifact-dir transcripts from candidates and counts them per parent', () => { + const workspace = `/home/user/.omp/agent/sessions/home-app-85dfa2f0` + const parent = `${workspace}/${SESSION_STEM}.jsonl` + const sibling = `${workspace}/2026-05-02T09-00-00-000Z_dddddddd-eeee-4fff-8aaa-111111111111.jsonl` + const partition = partitionOmpSubagentTranscriptPaths([ + parent, + `${workspace}/${SESSION_STEM}/AuthAndPreflight.jsonl`, + `${workspace}/${SESSION_STEM}/BitbucketDcApi.jsonl`, + // A grandchild attributes to its own parent, not the top-level session — + // and never surfaces as a candidate. + `${workspace}/${SESSION_STEM}/AuthAndPreflight/Nested.jsonl`, + sibling + ]) + + expect(partition.sessionFilePaths).toEqual([parent, sibling]) + expect(partition.subagentTranscriptCounts.get(parent)).toBe(2) + expect(partition.subagentTranscriptCounts.size).toBe(1) + }) + + it('attributes a child to its nearest stamped ancestor, not the outermost one', () => { + const workspace = '/home/user/.omp/agent/sessions/home-app-85dfa2f0' + const nested = '2026-05-02T09-00-00-000Z_dddddddd-eeee-4fff-8aaa-111111111111' + const partition = partitionOmpSubagentTranscriptPaths([ + `${workspace}/${SESSION_STEM}/${nested}/Grandchild.jsonl` + ]) + + expect(partition.sessionFilePaths).toEqual([]) + expect([...partition.subagentTranscriptCounts]).toEqual([ + [`${workspace}/${SESSION_STEM}/${nested}.jsonl`, 1] + ]) + }) + + it('handles Windows separators', () => { + const workspace = `C:\\Users\\u\\.omp\\agent\\sessions\\home-app-85dfa2f0` + const parent = `${workspace}\\${SESSION_STEM}.jsonl` + const partition = partitionOmpSubagentTranscriptPaths([ + parent, + `${workspace}\\${SESSION_STEM}\\AuthAndPreflight.jsonl` + ]) + + expect(partition.sessionFilePaths).toEqual([parent]) + expect(partition.subagentTranscriptCounts.get(parent)).toBe(1) + }) +}) + +describe('withOmpSubagentTranscriptCount host gating', () => { + const content = JSON.stringify({ + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }) + + it('never counts a local artifact dir for a remote-host transcript', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + // A local artifact dir exists at the same path, but the content came from + // an SSH host — its real task children live on that host, not on this disk. + await mkdir(join(workspace, SESSION_STEM), { recursive: true }) + await writeFile(join(workspace, SESSION_STEM, 'AuthAndPreflight.jsonl'), '') + const file = { path: parentPath, mtimeMs: 0, modifiedAt: '2026-05-01T10:00:00.000Z' } + + const remote = await parseMessageGraphSessionContent('omp', file, content, 'linux', { + executionHostId: 'ssh:host' + }) + const local = await parseMessageGraphSessionContent('omp', file, content, 'darwin') + + expect(remote?.subagentTranscriptCount).toBe(0) + expect(local?.subagentTranscriptCount).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts new file mode 100644 index 00000000000..9136e8c0d34 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -0,0 +1,119 @@ +import { readdir } from 'node:fs/promises' +import { basename, dirname, extname, join } from 'node:path' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' +import type { ResumableSessionParseState } from './session-scanner-types' + +// OMP names every session `_` and stores that session's +// artifacts — including its task-subagent transcripts — in a sibling directory +// of the same name (…//_.jsonl + …/_/). +// The anchored name pattern is the classification signal: task-child +// transcripts are label-named, so they can't match. Workspace dirs are encoded +// from a cwd and can't realistically match either, but the local prune doesn't +// rely on that — it skips depth 0 outright. This deliberately reads structure +// rather than a transcript's own records: children carry their own session id +// and no parentSession field at all, so lineage is not available here (#9330). +const OMP_SESSION_DIR_NAME = String.raw`\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` + +// Exported so discovery prunes exactly the subtrees this module reads on +// demand — the pruning predicate and the lister can't drift apart. +export const OMP_SESSION_ARTIFACT_DIR_PATTERN = new RegExp(`^${OMP_SESSION_DIR_NAME}$`, 'i') + +// Direct child of a session artifact dir: `…/_/`, so each one must survive the * same sanitizer the settings picker writes through. */ function isDashboardRepoIcons(value: unknown): boolean { @@ -264,8 +263,18 @@ function isDashboardCard(value: unknown): boolean { isBoundedString(card.worktreeId, MAX_ID_LENGTH) && isBoundedString(card.tabId, MAX_ID_LENGTH) && (card.leafId === null || isBoundedString(card.leafId, MAX_ID_LENGTH)) && + isOptionalBoundedString(card.parentPaneKey, MAX_ID_LENGTH) && + isOptionalBoundedString(card.parentWorktreeId, MAX_ID_LENGTH) && isBoundedString(card.repoName, MAX_LABEL_LENGTH, true) && isBoundedString(card.worktreeName, MAX_LABEL_LENGTH, true) && + (card.hostKind === undefined || + (typeof card.hostKind === 'string' && DASHBOARD_HOST_KINDS.has(card.hostKind))) && + (card.executionHostId === undefined || + (isBoundedString(card.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(card.executionHostId) !== null)) && + (card.workspaceKind === undefined || + (typeof card.workspaceKind === 'string' && + DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) && isOptionalBoundedString(card.workspaceStatusId, MAX_ID_LENGTH) && isOptionalBoundedString(card.workspaceStatusLabel, MAX_LABEL_LENGTH) && isOptionalBoundedString(card.workspaceStatusColor, MAX_ID_LENGTH) && @@ -275,6 +284,7 @@ function isDashboardCard(value: unknown): boolean { isFiniteNumber(card.startedAt) && (card.finishedAt === null || isFiniteNumber(card.finishedAt)) && isFiniteNumber(card.stateChangedAt) && + (card.statusUpdatedAt === undefined || isFiniteNumber(card.statusUpdatedAt)) && typeof card.unseen === 'boolean' && isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) && isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) && @@ -297,6 +307,7 @@ function isDashboardTerminalInput(value: unknown): boolean { isOptionalBoundedString(input.osRelease, MAX_LABEL_LENGTH) && typeof input.windowsShiftEnterEncoding === 'string' && WINDOWS_SHIFT_ENTER_ENCODINGS.has(input.windowsShiftEnterEncoding) && + typeof input.ctrlEnterCsiU === 'boolean' && typeof input.kittyKeyboardAdvertised === 'boolean' ) } diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index d516faa4554..ce10b3556fc 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -127,6 +127,15 @@ describe('registerDashboardPopoutHandlers', () => { expect(createPopoutMock).toHaveBeenCalledWith(store, undefined, { getKeybindings: expect.any(Function) }) + + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'map') + expect(createPopoutMock).toHaveBeenLastCalledWith(store, 'map', { + getKeybindings: expect.any(Function) + }) + + createPopoutMock.mockClear() + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'invalid') + expect(createPopoutMock).not.toHaveBeenCalled() }) it('auto-closes the popout when the feature is disabled', () => { @@ -240,6 +249,31 @@ describe('registerDashboardPopoutHandlers', () => { expect(sendToTrustedMock).toHaveBeenCalledWith('ui:ackDashboardAgent', 'tab1:leaf1') }) + it('relays only valid agent launches from the popout', () => { + const args = { worktreeId: 'worktree-1', agent: 'codex' } + handlers.get('dashboardPopout:spawnAgent')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, { + ...args, + agent: 'unknown' + }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, args) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:spawnDashboardAgent', args) + }) + + it('relays only valid sleep requests from the popout', () => { + const args = { worktreeId: 'worktree-1' } + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: popoutSender } as never, { + worktreeId: '' + }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: popoutSender } as never, args) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:sleepDashboardWorkspace', args) + }) + it('reveals an agent in only the trusted main window', () => { const main = makeWindow(mainSender) getTrustedWindowMock.mockReturnValue(main) diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts index c7a26421878..5b4781ec761 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -14,7 +14,9 @@ import { getTrustedUIRendererWindow, isTrustedUIRenderer, sendToTrustedUIRendere import { admitDashboardSnapshot, isDashboardPaneKey, - isDashboardRevealAgentArgs + isDashboardRevealAgentArgs, + isDashboardSleepWorkspaceArgs, + isDashboardSpawnAgentArgs } from './dashboard-payload-validation' // The most recent snapshot the main renderer published, replayed to the popout @@ -36,6 +38,8 @@ export function registerDashboardPopoutHandlers( ipcMain.removeHandler('dashboard:getPopoutOpen') ipcMain.removeHandler('dashboardPopout:revealAgent') ipcMain.removeHandler('dashboardPopout:ackAgent') + ipcMain.removeHandler('dashboardPopout:spawnAgent') + ipcMain.removeHandler('dashboardPopout:sleepWorkspace') onDashboardPopoutOpenChanged((open) => { if (!open) { @@ -52,11 +56,14 @@ export function registerDashboardPopoutHandlers( } }) - ipcMain.handle('dashboardPopout:open', (event): void => { + ipcMain.handle('dashboardPopout:open', (event, view: unknown): void => { if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) { return } - createOrFocusDashboardPopout(store, undefined, { + if (view !== undefined && view !== 'board' && view !== 'map') { + return + } + createOrFocusDashboardPopout(store, view, { getKeybindings: () => keybindings?.getOverrides() }) }) @@ -141,4 +148,26 @@ export function registerDashboardPopoutHandlers( // Best-effort; the per-window focus above may still bring it forward. } }) + + ipcMain.handle('dashboardPopout:spawnAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSpawnAgentArgs(args) + ) { + return + } + sendToTrustedUIRenderer('ui:spawnDashboardAgent', args) + }) + + ipcMain.handle('dashboardPopout:sleepWorkspace', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSleepWorkspaceArgs(args) + ) { + return + } + sendToTrustedUIRenderer('ui:sleepDashboardWorkspace', args) + }) } diff --git a/src/main/ipc/dashboard-workspace-payload-validation.ts b/src/main/ipc/dashboard-workspace-payload-validation.ts new file mode 100644 index 00000000000..88e16d9af45 --- /dev/null +++ b/src/main/ipc/dashboard-workspace-payload-validation.ts @@ -0,0 +1,80 @@ +import { + DASHBOARD_MAX_LABEL_LENGTH, + DASHBOARD_MAX_MAP_WORKSPACES, + type DashboardWorkspace +} from '../../shared/dashboard-snapshot' +import { normalizeExecutionHostId } from '../../shared/execution-host' + +const MAX_ID_LENGTH = 4_096 +const HOST_KINDS = new Set(['local', 'ssh', 'wsl', 'remote']) +const WORKSPACE_KINDS = new Set(['worktree', 'folder']) +const REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft']) + +function isString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalString(value: unknown, maxLength: number): boolean { + return value === undefined || isString(value, maxLength, true) +} + +function isReview(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const review = value as Record + return ( + typeof review.number === 'number' && + Number.isFinite(review.number) && + review.number > 0 && + typeof review.state === 'string' && + REVIEW_STATES.has(review.state) + ) +} + +export function isDashboardWorkspace(value: unknown): value is DashboardWorkspace { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const workspace = value as Record + return ( + isString(workspace.repoId, MAX_ID_LENGTH) && + isString(workspace.worktreeId, MAX_ID_LENGTH) && + isString(workspace.repoName, DASHBOARD_MAX_LABEL_LENGTH, true) && + isString(workspace.worktreeName, DASHBOARD_MAX_LABEL_LENGTH, true) && + isOptionalString(workspace.parentWorktreeId, MAX_ID_LENGTH) && + typeof workspace.hostKind === 'string' && + HOST_KINDS.has(workspace.hostKind) && + isString(workspace.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(workspace.executionHostId) !== null && + typeof workspace.workspaceKind === 'string' && + WORKSPACE_KINDS.has(workspace.workspaceKind) && + isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) && + isOptionalString(workspace.workspaceStatusLabel, DASHBOARD_MAX_LABEL_LENGTH) && + isOptionalString(workspace.workspaceStatusColor, MAX_ID_LENGTH) && + (workspace.hasReview === undefined || typeof workspace.hasReview === 'boolean') && + isReview(workspace.review) + ) +} + +export function isDashboardWorkspaceList(value: unknown): boolean { + return ( + value === undefined || + (Array.isArray(value) && + value.length <= DASHBOARD_MAX_MAP_WORKSPACES && + value.every(isDashboardWorkspace)) + ) +} + +export function admitDashboardWorkspaces(value: unknown): DashboardWorkspace[] | undefined | null { + if (value === undefined) { + return undefined + } + if (!Array.isArray(value) || value.length > DASHBOARD_MAX_MAP_WORKSPACES) { + return null + } + return value.filter(isDashboardWorkspace) +} diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index 78c8d6e0be0..5bece99b825 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -14,7 +14,8 @@ const { } = vi.hoisted(() => { const handleMock = vi.fn() const socketState = { - sendCallback: null as (() => void) | null + sendCallback: null as ((error?: Error | null) => void) | null, + errorListener: null as ((error: Error) => void) | null } const socketMock = { on: vi.fn(), @@ -65,6 +66,7 @@ vi.mock('../macos-full-disk-access-status', () => ({ getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock })) +import type { DeveloperPermissionState } from '../../shared/developer-permissions-types' import { registerDeveloperPermissionHandlers } from './developer-permissions' describe('registerDeveloperPermissionHandlers', () => { @@ -91,7 +93,13 @@ describe('registerDeveloperPermissionHandlers', () => { }) createSocketMock.mockClear() socketState.sendCallback = null - socketMock.on.mockClear() + socketState.errorListener = null + socketMock.on.mockReset() + socketMock.on.mockImplementation((event: string, listener: (error: Error) => void) => { + if (event === 'error') { + socketState.errorListener = listener + } + }) socketMock.removeListener.mockClear() socketMock.bind.mockReset() socketMock.bind.mockImplementation((callback: () => void) => callback()) @@ -103,7 +111,7 @@ describe('registerDeveloperPermissionHandlers', () => { _length: number, _port: number, _address: string, - callback: () => void + callback: (error?: Error | null) => void ) => { socketState.sendCallback = callback } @@ -127,6 +135,26 @@ describe('registerDeveloperPermissionHandlers', () => { return call[1] as (_event: unknown, args: { id: string }) => Promise } + function getStatusHandler(): () => Promise { + const call = handleMock.mock.calls.find( + (c: unknown[]) => c[0] === 'developerPermissions:getStatus' + ) + if (!call) { + throw new Error('developerPermissions:getStatus handler not registered') + } + return call[1] as () => Promise + } + + function getOpenSettingsHandler(): (_event: unknown, args: { id: string }) => Promise { + const call = handleMock.mock.calls.find( + (c: unknown[]) => c[0] === 'developerPermissions:openSettings' + ) + if (!call) { + throw new Error('developerPermissions:openSettings handler not registered') + } + return call[1] as (_event: unknown, args: { id: string }) => Promise + } + it('returns the Full Disk Access read-probe status', async () => { getMacosFullDiskAccessStatusMock.mockResolvedValue('granted') registerDeveloperPermissionHandlers() @@ -142,7 +170,7 @@ describe('registerDeveloperPermissionHandlers', () => { }) }) - it('clears the local-network prompt fallback timer when UDP send settles first', async () => { + it('keeps the local-network status unknown when UDP send settles without an error', async () => { registerDeveloperPermissionHandlers() const result = getRequestHandler()({}, { id: 'local-network' }) @@ -161,6 +189,37 @@ describe('registerDeveloperPermissionHandlers', () => { expect(socketMock.close).toHaveBeenCalledTimes(1) }) + it('keeps local-network status unknown when the prompt trigger receives a socket error', async () => { + registerDeveloperPermissionHandlers() + + const result = getRequestHandler()({}, { id: 'local-network' }) + socketState.errorListener?.( + Object.assign(new Error('send EHOSTUNREACH 224.0.0.251:5353'), { + code: 'EHOSTUNREACH' + }) + ) + + await expect(result).resolves.toEqual({ + id: 'local-network', + status: 'unknown', + openedSystemSettings: false + }) + await expect(getStatusHandler()()).resolves.toContainEqual({ + id: 'local-network', + status: 'unknown' + }) + }) + + it('opens the standard macOS Local Network settings pane', async () => { + registerDeveloperPermissionHandlers() + + await getOpenSettingsHandler()({}, { id: 'local-network' }) + + expect(shellOpenExternalMock).toHaveBeenCalledWith( + 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_LocalNetwork' + ) + }) + it('settles the automation prompt when osascript hangs', async () => { const killMock = vi.fn() execFileMock.mockImplementation(() => ({ kill: killMock })) diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index dc1171b8c7d..cfc10e61aef 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' import { ipcMain, shell, systemPreferences } from 'electron' import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' +import { testLocalNetworkConnection } from './local-network-connection-test' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, @@ -16,6 +17,8 @@ const PRIVACY_PANE_URLS: Partial> = { accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', 'full-disk-access': 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles', automation: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation', + 'local-network': + 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_LocalNetwork', bluetooth: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth' } @@ -235,4 +238,10 @@ export function registerDeveloperPermissionHandlers(): void { await openPrivacyPane(args.id) } ) + + ipcMain.handle('developerPermissions:testLocalNetworkConnection', async (_event, args: unknown) => + testLocalNetworkConnection( + args && typeof args === 'object' ? (args as { host?: unknown; port?: unknown }) : {} + ) + ) } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index d1c2ee312d0..ad68010cbb7 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -366,6 +366,30 @@ describe('registerFilesystemHandlers', () => { lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) }) + it('re-sorts SSH provider listings directories-first in natural order', async () => { + // Why: the remote relay may be an older build that still sorts lexicographically. + getSshFilesystemProviderMock.mockReturnValueOnce({ + readDir: vi.fn().mockResolvedValue([ + { name: '100 - b.txt', isDirectory: false, isSymlink: false }, + { name: '9 - c.txt', isDirectory: false, isSymlink: false }, + { name: '10 - dir', isDirectory: true, isSymlink: false }, + { name: '99 - a.txt', isDirectory: false, isSymlink: false } + ]) + }) + registerFilesystemHandlers(store as never) + + const result = (await handlers.get('fs:readDir')!(null, { + dirPath: '/remote/repo', + connectionId: 'ssh-1' + })) as { name: string }[] + expect(result.map((e) => e.name)).toEqual([ + '10 - dir', + '9 - c.txt', + '99 - a.txt', + '100 - b.txt' + ]) + }) + it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f3c91088d0a..4ca9547b77c 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -30,6 +30,7 @@ import type { } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { SshMutationExpectation } from '../../shared/ssh-types' +import { sortDirEntries } from '../../shared/file-name-sort' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { buildRgArgs, @@ -540,7 +541,9 @@ export function registerFilesystemHandlers( if (args.connectionId) { throwSite = 'ssh-provider' const provider = requireSshFilesystemProvider(args.connectionId) - return await provider.readDir(args.dirPath) + // Why: re-sort locally — the remote relay may be an older build with + // lexicographic ordering. + return sortDirEntries(await provider.readDir(args.dirPath)) } throwSite = 'authorize' const dirPath = await resolveAuthorizedPath(args.dirPath, store) @@ -555,12 +558,7 @@ export function registerFilesystemHandlers( isSymlink: entry.isSymbolicLink() })) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } catch (error: unknown) { recordCrashBreadcrumb( 'fs_readdir_error', @@ -1127,6 +1125,7 @@ export function registerFilesystemHandlers( includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string } ): Promise => { @@ -1134,6 +1133,9 @@ export function registerFilesystemHandlers( const options = { includeIgnored: args.includeIgnored ?? false, ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } : {}), diff --git a/src/main/ipc/local-network-connection-test.test.ts b/src/main/ipc/local-network-connection-test.test.ts new file mode 100644 index 00000000000..d9a62863b82 --- /dev/null +++ b/src/main/ipc/local-network-connection-test.test.ts @@ -0,0 +1,89 @@ +import { createServer } from 'node:net' +import { describe, expect, it, vi } from 'vitest' +import { testLocalNetworkConnection } from './local-network-connection-test' + +describe('testLocalNetworkConnection', () => { + it('runs a bounded Electron child probe and reports a verified connection', async () => { + const runChild = vi.fn((_file, _args, _options, callback) => callback(null, '', '')) + + await expect( + testLocalNetworkConnection( + { host: '192.168.1.20', port: 3000 }, + { platform: 'darwin', now: () => 1234, runChild } + ) + ).resolves.toEqual({ + ok: true, + host: '192.168.1.20', + port: 3000, + testedAt: 1234 + }) + expect(runChild).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining(['192.168.1.20', '3000']), + expect.objectContaining({ + timeout: 5000, + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }) + }), + expect.any(Function) + ) + }) + + it('rejects a hostname that resolves to loopback in the real child probe', async () => { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP listener address') + } + + try { + await expect( + testLocalNetworkConnection( + { host: '2130706433', port: address.port }, + { platform: 'darwin' } + ) + ).resolves.toMatchObject({ ok: false, failure: 'invalid-target' }) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + }) + + it.each([ + ['INVALID_TARGET', 'invalid-target'], + ['ETIMEDOUT', 'timeout'], + ['ECONNREFUSED', 'refused'], + ['EHOSTUNREACH', 'unreachable'], + ['ENETUNREACH', 'unreachable'], + ['ENOTFOUND', 'unresolved'] + ] as const)('maps %s without claiming a permission denial', async (stderr, failure) => { + const runChild = vi.fn((_file, _args, _options, callback) => + callback(Object.assign(new Error(stderr), { code: 1 }), '', stderr) + ) + + await expect( + testLocalNetworkConnection( + { host: 'devbox.local', port: 8080 }, + { platform: 'darwin', runChild } + ) + ).resolves.toMatchObject({ ok: false, failure }) + }) + + it.each([ + { host: '127.0.0.1', port: 3000 }, + { host: 'localhost', port: 3000 }, + { host: 'service.localhost', port: 3000 }, + { host: '8.8.8.8', port: 53 }, + { host: 'https://devbox.local', port: 443 }, + { host: 'devbox.local', port: 0 } + ])('rejects a target that does not test a LAN connection: $host:$port', async (target) => { + const runChild = vi.fn() + + await expect( + testLocalNetworkConnection(target, { platform: 'darwin', runChild }) + ).resolves.toMatchObject({ ok: false, failure: 'invalid-target' }) + expect(runChild).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/local-network-connection-test.ts b/src/main/ipc/local-network-connection-test.ts new file mode 100644 index 00000000000..5cc7c35f4ee --- /dev/null +++ b/src/main/ipc/local-network-connection-test.ts @@ -0,0 +1,167 @@ +import { execFile, type ExecFileException, type ExecFileOptions } from 'node:child_process' +import { isIP } from 'node:net' +import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' +import type { + LocalNetworkConnectionTestFailure, + LocalNetworkConnectionTestResult +} from '../../shared/developer-permissions-types' + +const CONNECT_TIMEOUT_MS = 4_000 +const CHILD_TIMEOUT_MS = CONNECT_TIMEOUT_MS + 1_000 +const CONNECT_SCRIPT = ` +const net = require('node:net') +const host = process.argv[1] +const port = Number(process.argv[2]) +const socket = net.createConnection({ host, port }) +let settled = false +function finish(code) { + if (settled) return + settled = true + if (code) { + process.stderr.write(code) + process.exitCode = 1 + } + socket.destroy() +} +function isLanAddress(address) { + const normalized = (address || '').toLowerCase().split('%', 1)[0] + const mapped = normalized.match(/^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/)?.[1] + const ipv4 = mapped || (net.isIP(normalized) === 4 ? normalized : null) + if (ipv4) { + const octets = ipv4.split('.').map(Number) + return octets[0] === 10 || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] === 192 && octets[1] === 168) || + (octets[0] === 169 && octets[1] === 254) + } + if (net.isIP(normalized) === 6) { + const first = Number.parseInt(normalized.split(':')[0], 16) + return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80 + } + return false +} +socket.setTimeout(${CONNECT_TIMEOUT_MS}) +socket.once('connect', () => finish(isLanAddress(socket.remoteAddress) ? undefined : 'INVALID_TARGET')) +socket.once('timeout', () => finish('ETIMEDOUT')) +socket.once('error', (error) => finish(error.code || 'FAILED')) +` + +type ConnectionChildRunner = ( + file: string, + args: string[], + options: ExecFileOptions, + callback: (error: ExecFileException | null, stdout: string, stderr: string) => void +) => unknown + +type LocalNetworkConnectionTestOptions = { + platform?: NodeJS.Platform + now?: () => number + runChild?: ConnectionChildRunner +} + +function normalizeTargetHost(rawHost: unknown): string | null { + if (typeof rawHost !== 'string') { + return null + } + const host = rawHost.trim().replace(/^\[|\]$/g, '') + if ( + !host || + host.startsWith('-') || + host.length > 253 || + /[\s/\\]/.test(host) || + host.includes('://') || + !/^[a-z0-9.:%_-]+$/i.test(host) + ) { + return null + } + const addressWithoutZone = host.split('%', 1)[0] ?? host + const kind = classifyRemotePairingHostname(addressWithoutZone) + if (isIP(addressWithoutZone) === 0) { + return kind === 'loopback' ? null : host + } + const isLinkLocalIpv4 = addressWithoutZone.startsWith('169.254.') + return kind === 'lan' || isLinkLocalIpv4 ? host : null +} + +function normalizeTargetPort(rawPort: unknown): number | null { + return typeof rawPort === 'number' && + Number.isInteger(rawPort) && + rawPort >= 1 && + rawPort <= 65_535 + ? rawPort + : null +} + +function failureFromChild( + error: ExecFileException, + stderr: string +): LocalNetworkConnectionTestFailure { + if (error.killed || error.signal) { + return 'timeout' + } + const code = stderr.trim().split(/\s/, 1)[0] + switch (code) { + case 'INVALID_TARGET': + return 'invalid-target' + case 'ETIMEDOUT': + return 'timeout' + case 'ECONNREFUSED': + return 'refused' + case 'EHOSTUNREACH': + case 'ENETUNREACH': + return 'unreachable' + case 'ENOTFOUND': + case 'EAI_AGAIN': + return 'unresolved' + default: + return 'failed' + } +} + +export function testLocalNetworkConnection( + args: { host?: unknown; port?: unknown }, + options: LocalNetworkConnectionTestOptions = {} +): Promise { + const testedAt = (options.now ?? Date.now)() + const host = normalizeTargetHost(args.host) + const port = normalizeTargetPort(args.port) + if (!host || !port) { + return Promise.resolve({ + ok: false, + host: typeof args.host === 'string' ? args.host.trim() : '', + port: typeof args.port === 'number' ? args.port : 0, + testedAt, + failure: 'invalid-target' + }) + } + if ((options.platform ?? process.platform) !== 'darwin') { + return Promise.resolve({ ok: false, host, port, testedAt, failure: 'unsupported' }) + } + + const runChild = options.runChild ?? (execFile as ConnectionChildRunner) + return new Promise((resolve) => { + runChild( + process.execPath, + ['-e', CONNECT_SCRIPT, host, String(port)], + { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + timeout: CHILD_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 1024 + }, + (error, _stdout, stderr) => { + if (!error) { + resolve({ ok: true, host, port, testedAt }) + return + } + resolve({ + ok: false, + host, + port, + testedAt, + failure: failureFromChild(error, stderr) + }) + } + ) + }) +} diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 9f81c3a6516..51ce4a6c9f1 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -126,6 +126,132 @@ describe('registerMobileHandlers', () => { }) }) + it('ranks container and VM bridges below every reachable address', () => { + // Why: a phone can never reach docker0, so advertising it makes the direct + // path lose the pairing race and silently relays every session. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }], + 'vEthernet (Default Switch)': [{ family: 'IPv4', internal: false, address: '172.28.80.1' }], + bridge0: [{ family: 'IPv4', internal: false, address: '169.254.60.1' }], + en0: [ + { family: 'IPv6', internal: false, address: '2605:340:cd51:2a01:0:2b13:f279:c096' }, + { family: 'IPv4', internal: false, address: '192.168.1.24' } + ] + }) + + registerMobileHandlers({} as never) + + // Real IPv6 outranks a bridge IPv4: the phone can reach one, never the other. + expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + interfaces: [ + { name: 'en0', address: '192.168.1.24' }, + { name: 'en0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' }, + { name: 'docker0', address: '172.17.0.1' }, + { name: 'vEthernet (Default Switch)', address: '172.28.80.1' }, + { name: 'bridge0', address: '169.254.60.1' } + ] + }) + }) + + it('keeps a real LAN address that merely overlaps a container subnet', () => { + // Why: Docker's 172.16/12 pool overlaps genuine corporate LANs, so the + // bridge check keys on interface name — a subnet test would demote this. + networkInterfacesMock.mockReturnValue({ + eth0: [{ family: 'IPv4', internal: false, address: '172.17.4.9' }], + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] + }) + + registerMobileHandlers({} as never) + + expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + interfaces: [ + { name: 'eth0', address: '172.17.4.9' }, + { name: 'docker0', address: '172.17.0.1' } + ] + }) + }) + + it('never auto-advertises a bridge: a bridge-only host pairs over Relay with no address', async () => { + // Why: a bridge address the phone provably cannot reach must not become the default, and Relay + // needs no local address — so the QR ships without a direct path instead of an unreachable one. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }], + 'vEthernet (WSL)': [{ family: 'IPv4', internal: false, address: '172.28.80.1' }] + }) + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#relay', + endpoint: 'ws://127.0.0.1:6768', + deviceId: 'mobile-bridge-only', + connectionMode: 'automatic' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + + await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({ + available: true, + connectionMode: 'automatic', + // Why: the offer's loopback fallback points at the scanning phone, not this host — reporting it + // would print a direct endpoint under the QR that nothing can dial. + endpoint: null + }) + expect(createMobilePairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ address: null }) + ) + // The bridges stay pickable, just never automatically. + expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + interfaces: [ + { name: 'docker0', address: '172.17.0.1' }, + { name: 'vEthernet (WSL)', address: '172.28.80.1' } + ] + }) + }) + + it('refuses a LAN-only QR on a bridge-only host instead of advertising the bridge', async () => { + // Why: LAN has no Relay to fall back on, so a dead direct endpoint is worse than saying so — + // the guidance points at the picker, where the bridge is still selectable on purpose. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] + }) + const createMobilePairingOffer = vi.fn() + + registerMobileHandlers({ createMobilePairingOffer } as never) + + await expect( + handlers.get('mobile:getPairingQR')?.(null, { connectionMode: 'local-only' }) + ).resolves.toMatchObject({ + available: false, + reason: 'invalid_advertised_endpoint' + }) + expect(createMobilePairingOffer).not.toHaveBeenCalled() + }) + + it('honors an explicitly picked bridge address', async () => { + // Why: exclusion is about the automatic default only — a user who knows their bridge is routable + // (a VM guest pairing with the host) must still be able to advertise it. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] + }) + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#bridge', + endpoint: 'ws://172.17.0.1:6768', + deviceId: 'mobile-bridge-pick', + connectionMode: 'local-only' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + await handlers.get('mobile:getPairingQR')?.(null, { + address: '172.17.0.1', + connectionMode: 'local-only' + }) + + expect(createMobilePairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ address: '172.17.0.1' }) + ) + }) + it('returns an IPv6 interface on an IPv6-only host (regression: was empty, breaking mobile pairing)', () => { networkInterfacesMock.mockReturnValue({ eth0: [ @@ -343,6 +469,23 @@ describe('registerMobileHandlers', () => { ensureNetworkExposure: vi.fn().mockResolvedValue(undefined) }) + it('reports runtime pairing unavailable rather than advertising a bridge', async () => { + // Why: runtime clients have no Relay fallback, so a bridge-only host has nothing reachable to + // advertise — and no widen should happen for a link that would be dead anyway. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] + }) + const rpcServer = stubRuntimePairingServer('172.17.0.1:6768') + + registerMobileHandlers(rpcServer as never) + + await expect(handlers.get('mobile:getRuntimePairingUrl')?.(null, {})).resolves.toEqual({ + available: false + }) + expect(rpcServer.createPairingOffer).not.toHaveBeenCalled() + expect(rpcServer.ensureNetworkExposure).not.toHaveBeenCalled() + }) + // Why: every loopback form the address field accepts must behave identically once the user declared // "This computer only" — `splitHostPort`/`ws://` overrides all reach the handler verbatim, so gating on // the raw string alone treated `localhost:8443` and `ws://127.0.0.1:6768` as off-host. diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 35584f951a4..637d579cae8 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,6 +2,10 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import { + isVirtualBridgeInterface, + selectAutoAdvertisedPairingAddress +} from '../../shared/pairing-address-auto-selection' import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { isTailnetIPv4Address } from '../../shared/tailnet-address' @@ -64,20 +68,24 @@ function getNetworkInterfaces(): NetworkInterface[] { } } // Why: prefer tailnet IPv4 first (most portable across networks), then other - // IPv4, then IPv6 as a fallback for IPv6-only environments. - return result.sort((a, b) => rankAddress(a.address) - rankAddress(b.address)) + // IPv4, then IPv6 as a fallback for IPv6-only environments. Virtual bridges + // sort below all of them and stay in the list so they remain explicitly + // pickable; the automatic default skips them entirely. + return result.sort((a, b) => rankInterface(a) - rankInterface(b)) } -function rankAddress(address: string): number { +function rankInterface({ name, address }: NetworkInterface): number { if (isTailnetIPv4Address(address)) { return 0 } - return address.includes(':') ? 2 : 1 + const bridgePenalty = isVirtualBridgeInterface(name) ? 2 : 0 + return (address.includes(':') ? 2 : 1) + bridgePenalty } +// Why: null when every enumerated interface is a host-local bridge, so a docker0-only host +// advertises no direct address instead of one the phone provably cannot reach. function getDefaultPairingAddress(): string | null { - const ifaces = getNetworkInterfaces() - return ifaces.length > 0 ? ifaces[0]!.address : null + return selectAutoAdvertisedPairingAddress(getNetworkInterfaces()) ?? null } // Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it @@ -141,7 +149,11 @@ export function registerMobileHandlers( // embed in the QR code. This supports overlay networks (Tailscale, // ZeroTier) where the default LAN IP isn't reachable from the phone. const ip = args?.address ?? getDefaultPairingAddress() - if (!ip) { + // Why: the local address is optional under Relay — the QR carries the relay invite, so a host + // with nothing auto-advertisable (only container bridges, or no interface at all) still pairs. + // The offer's endpoint then falls back to loopback, which is the phone's own device: the direct + // candidate loses the race by construction. LAN-only has no relay to fall back on, so it fails closed. + if (!ip && args?.connectionMode === 'local-only') { return { available: false as const, reason: 'invalid_advertised_endpoint', @@ -181,7 +193,10 @@ export function registerMobileHandlers( qrDataUrl: qr.ok ? qr.qrDataUrl : null, ...(!qr.ok ? { qrError: qr.reason } : {}), pairingUrl: offer.pairingUrl, - endpoint: offer.endpoint, + // Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at + // whichever device scans the QR — never this host. Report no endpoint so the UI omits it + // instead of printing an address the phone can't reach. + endpoint: ip ? offer.endpoint : null, deviceId: offer.deviceId, connectionMode: offer.connectionMode } diff --git a/src/main/ipc/parcel-watcher-crash-fuse.test.ts b/src/main/ipc/parcel-watcher-crash-fuse.test.ts new file mode 100644 index 00000000000..740dafbb7ff --- /dev/null +++ b/src/main/ipc/parcel-watcher-crash-fuse.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse' + +describe('WatcherProcessCrashFuse', () => { + it('opens after three crashes at the watcher restart cadence', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(80_000) + + expect(fuse.isOpen(80_000)).toBe(true) + }) + + it('stays open until explicitly reset', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(80_000) + + expect(fuse.isOpen(10 * 60_000)).toBe(true) + fuse.reset() + expect(fuse.isOpen(10 * 60_000)).toBe(false) + }) + + it('expires crashes outside the two-minute window', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(120_000) + + expect(fuse.isOpen(120_000)).toBe(false) + }) +}) diff --git a/src/main/ipc/parcel-watcher-crash-fuse.ts b/src/main/ipc/parcel-watcher-crash-fuse.ts index 69717311ba5..428eafe5a1e 100644 --- a/src/main/ipc/parcel-watcher-crash-fuse.ts +++ b/src/main/ipc/parcel-watcher-crash-fuse.ts @@ -1,21 +1,30 @@ -const CRASH_WINDOW_MS = 30_000 +const CRASH_WINDOW_MS = 2 * 60_000 const MAX_CRASHES_PER_WINDOW = 3 export class WatcherProcessCrashFuse { private crashTimes: number[] = [] + private open = false recordCrash(now = Date.now()): void { + if (this.open) { + return + } this.removeExpired(now) this.crashTimes.push(now) + this.open = this.crashTimes.length >= MAX_CRASHES_PER_WINDOW } isOpen(now = Date.now()): boolean { + if (this.open) { + return true + } this.removeExpired(now) - return this.crashTimes.length >= MAX_CRASHES_PER_WINDOW + return false } reset(): void { this.crashTimes = [] + this.open = false } private removeExpired(now: number): void { diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index 805281bbea6..be5000fc49a 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -656,6 +656,37 @@ describe('subscribeViaWatcherProcess', () => { ) }) + it('does not respawn for later subscriptions after the crash fuse opens', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(0) + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + ackSubscribe(currentChild()) + await promise + + for (const crashTime of [0, 40_000]) { + vi.setSystemTime(crashTime) + const child = currentChild() + child.connected = false + child.emit('exit', 3221226505, null) + ackSubscribe(currentChild()) + } + vi.setSystemTime(80_000) + const last = currentChild() + last.connected = false + last.emit('exit', 3221226505, null) + expect(forkMock).toHaveBeenCalledTimes(3) + + vi.setSystemTime(10 * 60_000) + await expect(subscribeViaWatcherProcess('/later', vi.fn(), {})).rejects.toMatchObject({ + code: 'process_unavailable' + }) + expect(forkMock).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + it('kills the idle child after the last unsubscribe and respawns on the next subscribe', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const first = currentChild() diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index 1586c566ffb..d1c2689000b 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -1,14 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DaemonSessionInfo } from '../daemon/types' -const { handleMock, removeHandlerMock, getDaemonProviderMock, restartDaemonMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - removeHandlerMock: vi.fn(), - getDaemonProviderMock: vi.fn(), - restartDaemonMock: vi.fn() - }) -) +const { + handleMock, + removeHandlerMock, + getDaemonProviderMock, + restartDaemonMock, + getCurrentDaemonMacTccAttributionHealthMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + getDaemonProviderMock: vi.fn(), + restartDaemonMock: vi.fn(), + getCurrentDaemonMacTccAttributionHealthMock: vi.fn(async () => 'unknown') +})) vi.mock('electron', () => ({ ipcMain: { handle: handleMock, removeHandler: removeHandlerMock } @@ -16,7 +21,8 @@ vi.mock('electron', () => ({ vi.mock('../daemon/daemon-init', () => ({ getDaemonProvider: getDaemonProviderMock, - restartDaemon: restartDaemonMock + restartDaemon: restartDaemonMock, + getCurrentDaemonMacTccAttributionHealth: getCurrentDaemonMacTccAttributionHealthMock })) // Why: the handler uses `provider instanceof DaemonPtyRouter` to branch @@ -140,6 +146,8 @@ describe('pty:management IPC handlers', () => { beforeEach(() => { getDaemonProviderMock.mockReset() restartDaemonMock.mockReset() + getCurrentDaemonMacTccAttributionHealthMock.mockReset() + getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('unknown') }) afterEach(() => { @@ -456,6 +464,36 @@ describe('pty:management IPC handlers', () => { }) }) + describe('macTccAttribution', () => { + it('reports the daemon attribution health', async () => { + getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('severed') + + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:macTccAttribution']({})) as { + health: string + } + + expect(result.health).toBe('severed') + }) + + it('fails open to unknown when the probe throws', async () => { + getCurrentDaemonMacTccAttributionHealthMock.mockRejectedValue(new Error('no pid record')) + + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:macTccAttribution']({})) as { + health: string + } + + expect(result.health).toBe('unknown') + }) + }) + describe('restart', () => { it('delegates to restartDaemon and reports success', async () => { restartDaemonMock.mockResolvedValue({ killedCount: 2 }) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 6a3f455aeea..16e6cfb41ac 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -2,7 +2,12 @@ import { ipcMain } from 'electron' import { DaemonPtyRouter } from '../daemon/daemon-pty-router' import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' -import { getDaemonProvider, restartDaemon } from '../daemon/daemon-init' +import { + getCurrentDaemonMacTccAttributionHealth, + getDaemonProvider, + restartDaemon +} from '../daemon/daemon-init' +import type { MacDaemonTccAttributionHealth } from '../daemon/daemon-health' import type { DaemonSessionInfo } from '../daemon/types' // Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused". @@ -51,6 +56,19 @@ export function registerDaemonManagementHandlers(): void { ipcMain.removeHandler('pty:management:killAll') ipcMain.removeHandler('pty:management:killOne') ipcMain.removeHandler('pty:management:restart') + ipcMain.removeHandler('pty:management:macTccAttribution') + + // Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491). + ipcMain.handle( + 'pty:management:macTccAttribution', + async (): Promise<{ health: MacDaemonTccAttributionHealth }> => { + try { + return { health: await getCurrentDaemonMacTccAttributionHealth() } + } catch { + return { health: 'unknown' } + } + } + ) ipcMain.handle( 'pty:management:listSessions', diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c91c7d23dfd..678cc877465 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -14,6 +14,7 @@ import type { TuiAgent } from '../../shared/types' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -636,6 +637,7 @@ describe('registerPtyHandlers', () => { const write = vi.fn() const pauseProducer = vi.fn() const resumeProducer = vi.fn() + const setPtyBackgrounded = vi.fn() const shutdown = vi.fn() let dataHandler: ((payload: { id: string; data: string }) => void) | null = null let exitHandler: ((payload: { id: string; code: number }) => void) | null = null @@ -649,6 +651,7 @@ describe('registerPtyHandlers', () => { resize: vi.fn(), pauseProducer, resumeProducer, + setPtyBackgrounded, kill: vi.fn(), shutdown, sendSignal: vi.fn(), @@ -687,6 +690,7 @@ describe('registerPtyHandlers', () => { write, pauseProducer, resumeProducer, + setPtyBackgrounded, shutdown, getBufferSnapshot, emitData: (id: string, data: string) => dataHandler?.({ id, data }), @@ -9009,14 +9013,15 @@ describe('registerPtyHandlers', () => { } ) - it('adopts an exact persisted owner when the runtime projection is missing', async () => { + it('repairs a stale persisted incarnation after exact same-id reattach', async () => { const tabId = 'tab-persisted-owner' const leafId = '88888888-8888-4888-8888-888888888888' const paneKey = makePaneKey(tabId, leafId) const worktreeId = 'repo-1::/tmp/persisted-owner' + let attachAttempt = 0 const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => ({ id: options.sessionId ?? 'unexpected-fresh-id', - incarnationId: 'inc-persisted-owner', + incarnationId: attachAttempt++ === 1 ? 'inc-wrong-owner' : 'inc-live-owner', isReattach: options.attachOnly === true, snapshot: 'persisted-owner-output' })) @@ -9067,10 +9072,10 @@ describe('registerPtyHandlers', () => { [tabId]: { ptyIdsByLeafId: { [leafId]: 'pty-persisted-owner' } } }, terminalPtyIncarnationsByPaneKey: { - [paneKey]: 'inc-persisted-owner' + [paneKey]: 'inc-stale-owner' } })), - persistPtyBinding: vi.fn() + persistPtyBinding: vi.fn(() => true) } registerPtyHandlers( @@ -9081,7 +9086,7 @@ describe('registerPtyHandlers', () => { undefined, store as never ) - const mounted = await handlers.get('pty:spawn')!(null, { + const spawnArgs = { cols: 80, rows: 24, cwd: '/tmp/persisted-owner', @@ -9094,18 +9099,22 @@ describe('registerPtyHandlers', () => { ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } - }) + } + + const mounted = await handlers.get('pty:spawn')!(null, spawnArgs) expect(mounted).toMatchObject({ id: 'pty-persisted-owner', - incarnationId: 'inc-persisted-owner', + incarnationId: 'inc-live-owner', isReattach: true }) - expect(providerSpawn).toHaveBeenCalledOnce() - expect(providerSpawn).toHaveBeenCalledWith( + expect(providerSpawn).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ attachOnly: true, sessionId: 'pty-persisted-owner', + expectedIncarnationId: 'inc-stale-owner', + expectedIncarnationIsAuthoritative: false, command: undefined }) ) @@ -9115,10 +9124,50 @@ describe('registerPtyHandlers', () => { ) expect(runtime.noteTerminalSpawnCommand).not.toHaveBeenCalled() expect(store.persistPtyBinding).toHaveBeenCalledOnce() + expect(store.persistPtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId, + tabId, + leafId, + ptyId: 'pty-persisted-owner', + incarnationId: 'inc-live-owner', + expectedBinding: { + ptyId: 'pty-persisted-owner', + incarnationId: 'inc-stale-owner' + } + }), + undefined + ) expect( mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:spawned') ).toHaveLength(1) expect(runtime.onPtyExit).not.toHaveBeenCalled() + + store.persistPtyBinding.mockClear() + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow( + 'terminal_pane_owner_changed' + ) + expect(store.persistPtyBinding).not.toHaveBeenCalled() + + runtime.assertPtyRegistrationAllowed.mockImplementationOnce(() => { + throw new Error('agent_session_exited_during_start') + }) + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow( + 'agent_session_exited_during_start' + ) + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledTimes(3) + expect(providerSpawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + attachOnly: true, + sessionId: 'pty-persisted-owner', + expectedIncarnationId: 'inc-live-owner', + expectedIncarnationIsAuthoritative: true, + command: undefined + }) + ) + clearProviderPtyState('pty-persisted-owner') }) it.each([ @@ -9295,29 +9344,22 @@ describe('registerPtyHandlers', () => { } ) - it.each([ - { label: 'another owner reports it alive', liveness: true }, - { label: 'no owner could answer', liveness: null } - ])('keeps a persisted owner whose absence is unproven ($label)', async ({ liveness }) => { + it('keeps a persisted owner when daemon routing is unresolved', async () => { const worktreeId = 'repo-1::/tmp/unproven-owner' const cwd = '/tmp/unproven-owner' const tabId = 'tab-unproven-owner' const leafId = '56565656-5656-4656-8656-565656565656' const paneKey = makePaneKey(tabId, leafId) - // Why: a degraded router answers unmapped ids from the local fallback, which never - // owned this daemon session — the same "Session not found" a truly dead PTY yields. const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-unproven-owner') + throw new TerminalSessionOwnerUnverifiedError('pty-unproven-owner') } return { id: 'pty-fresh-unproven', incarnationId: 'inc-fresh-unproven' } } ) - const probePtyLiveness = vi.fn(async () => liveness) setLocalPtyProvider({ spawn: providerSpawn, - probePtyLiveness, write: vi.fn(), resize: vi.fn(), kill: vi.fn(), @@ -9410,7 +9452,6 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow('terminal_pane_owner_unverified') - expect(probePtyLiveness).toHaveBeenCalledWith('pty-unproven-owner') // The live PTY keeps its pane binding, gets no synthetic exit, and is not duplicated. expect(providerSpawn).toHaveBeenCalledOnce() expect(providerSpawn.mock.calls[0]?.[0]).toMatchObject({ attachOnly: true }) @@ -9420,11 +9461,7 @@ describe('registerPtyHandlers', () => { expect(session.tabsByWorktree[worktreeId]).toHaveLength(1) }) - // Why the positive direction needs its own case: the sibling retire tests use providers with - // no `probePtyLiveness`, so they skip this guard entirely. Without this, the guard could be - // strengthened into a permanent veto — no daemon-backed pane could ever recover from a dead - // owner — and every suite would stay green. - it('still retires and respawns when a provider proves the owner is absent', async () => { + it('still retires and respawns when the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/proven-absent-owner' const cwd = '/tmp/proven-absent-owner' const tabId = 'tab-proven-absent-owner' @@ -9438,10 +9475,8 @@ describe('registerPtyHandlers', () => { return { id: 'pty-fresh-proven', incarnationId: 'inc-fresh-proven' } } ) - const probePtyLiveness = vi.fn(async () => false) setLocalPtyProvider({ spawn: providerSpawn, - probePtyLiveness, write: vi.fn(), resize: vi.fn(), kill: vi.fn(), @@ -9532,8 +9567,6 @@ describe('registerPtyHandlers', () => { } }) - expect(probePtyLiveness).toHaveBeenCalledWith('pty-proven-absent-owner') - // Proven absence is the one answer that authorizes retirement, so recovery must proceed. expect(mounted).toMatchObject({ id: 'pty-fresh-proven' }) expect(providerSpawn).toHaveBeenCalledTimes(2) expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ @@ -9548,6 +9581,124 @@ describe('registerPtyHandlers', () => { expect(store.flushOrThrow).toHaveBeenCalledOnce() }) + it('does not poll after the routed provider confirms absence', async () => { + const worktreeId = 'repo-1::/tmp/probe-blip-owner' + const cwd = '/tmp/probe-blip-owner' + const tabId = 'tab-probe-blip-owner' + const leafId = '67676767-6767-4767-8767-676767676767' + const paneKey = makePaneKey(tabId, leafId) + const providerSpawn = vi.fn( + async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { + if (options.attachOnly) { + throw new Error('Session not found: pty-probe-blip-owner') + } + return { id: 'pty-fresh-probe-blip', incarnationId: 'inc-fresh-probe-blip' } + } + ) + const probePtyLiveness = vi.fn(async () => null) + setLocalPtyProvider({ + spawn: providerSpawn, + probePtyLiveness, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let session = { + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-probe-blip-owner' }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-probe-blip-owner' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-owner' } + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-probe-blip'), + preAllocateHandleForPty: vi.fn(() => 'term-probe-blip'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + const mounted = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + command: 'codex resume probe-blip-session', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + + expect(probePtyLiveness).not.toHaveBeenCalled() + expect(mounted).toMatchObject({ id: 'pty-fresh-probe-blip' }) + expect(providerSpawn).toHaveBeenCalledTimes(2) + expect(runtime.onPtyExit).toHaveBeenCalledWith( + 'pty-probe-blip-owner', + 0, + 'inc-probe-blip-owner' + ) + }) + // Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while // persistence has already dropped it. Reading "nothing left to retire" as a competing owner // aborted materialization *after* signalling the exit, which destroyed the pane instead of @@ -9676,7 +9827,7 @@ describe('registerPtyHandlers', () => { } }) - expect(probePtyLiveness).toHaveBeenCalledWith('pty-already-retired-owner') + expect(probePtyLiveness).not.toHaveBeenCalled() expect(mounted).toMatchObject({ id: 'pty-fresh-already-retired' }) expect(providerSpawn).toHaveBeenCalledTimes(2) expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ @@ -15460,6 +15611,50 @@ describe('registerPtyHandlers', () => { }) describe('hidden renderer delivery gate', () => { + it('foregrounds a preserved daemon PTY after handler recreation loses sync memory', async () => { + const daemon = installObservableDaemonTestProvider() + const firstRuntime = { + setPtyController: vi.fn(), + hasRawTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, firstRuntime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + + getPtySetRendererPtyVisibleListener()(null, { id: result.id, visible: false }) + expect(daemon.setPtyBackgrounded).toHaveBeenLastCalledWith(result.id, true) + + daemon.setPtyBackgrounded.mockClear() + handlers.clear() + let rawSubscriberPresent = false + const nextRuntime = { + setPtyController: vi.fn(), + hasRawTerminalViewSubscriber: vi.fn(() => rawSubscriberPresent), + onRemoteTerminalViewPresenceChanged: null as ((id: string) => void) | null, + registerRawTerminalViewSubscriber(id: string): void { + rawSubscriberPresent = true + this.onRemoteTerminalViewPresenceChanged?.(id) + } + } + registerPtyHandlers(mainWindow as never, nextRuntime as never) + + nextRuntime.registerRawTerminalViewSubscriber(result.id) + // Repeated presence signals must remain deduplicated. + nextRuntime.onRemoteTerminalViewPresenceChanged?.(result.id) + + expect(daemon.setPtyBackgrounded).toHaveBeenCalledOnce() + expect(daemon.setPtyBackgrounded).toHaveBeenCalledWith(result.id, false) + }) + it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => { vi.useFakeTimers() const runtime = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e4f50a62501..46498fdf0a3 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -93,6 +93,7 @@ import { import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' +import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -599,6 +600,9 @@ type StablePaneOwner = { leafId: string ptyId: string incarnationId?: string + hasPersistedBinding?: true + persistedIncarnationId?: string + runtimeIncarnationId?: string } type StablePaneAdoption = { result: PtySpawnResult @@ -677,13 +681,6 @@ function resolveStablePaneOwner( throw new Error('terminal_pane_owner_host_mismatch') } const runtimeIncarnationId = ptyIncarnationById.get(ptyId) - if ( - runtimeIncarnationId && - persisted?.incarnationId && - runtimeIncarnationId !== persisted.incarnationId - ) { - throw new Error('terminal_pane_owner_conflict') - } const parsed = parsePaneKey(paneKey) if (!parsed) { return null @@ -695,7 +692,10 @@ function resolveStablePaneOwner( ptyId, ...(runtimeIncarnationId || persisted?.incarnationId ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } - : {}) + : {}), + ...(persisted ? { hasPersistedBinding: true as const } : {}), + ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), + ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) } } @@ -717,7 +717,7 @@ function retirePersistedStablePaneOwner( // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. return true } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.incarnationId) { + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { return false } const session = store.getWorkspaceSession(hostId) @@ -726,7 +726,7 @@ function retirePersistedStablePaneOwner( parentTabId: owner.tabId, leafId: owner.leafId, ptyId: owner.ptyId, - ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) }) if (retired === session) { return false @@ -748,6 +748,47 @@ type StablePaneSpawnContext = { onFreshSpawn?: (result: PtySpawnResult) => void } +function stablePanePersistenceFence( + owner: StablePaneOwner | null +): { ptyId: string; incarnationId?: string } | undefined { + return owner?.hasPersistedBinding + ? { + ptyId: owner.ptyId, + ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) + } + : undefined +} + +function persistAdmittedStablePaneBinding(args: { + store: Store | undefined + owner: StablePaneOwner | null + result: PtySpawnResult + worktreeId: string | undefined + startupCwd: string | undefined + connectionId: string | null | undefined +}): boolean { + const expectedBinding = stablePanePersistenceFence(args.owner) + if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { + return false + } + const persisted = args.store.persistPtyBinding( + { + worktreeId: args.worktreeId, + tabId: args.owner.tabId, + leafId: args.owner.leafId, + ptyId: args.result.id, + ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + expectedBinding + }, + args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + ) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + return true +} + async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { @@ -758,6 +799,8 @@ async function attachStablePaneOwner( ...spawnOptions, sessionId: owner.ptyId, attachOnly: true, + expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, + expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, isNewSession: undefined, command: undefined, commandDelivery: undefined, @@ -769,25 +812,19 @@ async function attachStablePaneOwner( onPtySpawnCommitted: undefined }) } catch (error) { + if (error instanceof TerminalSessionOwnerUnverifiedError) { + throw new Error('terminal_pane_owner_unverified') + } if (!isPtyAlreadyGoneError(error)) { throw error } - // Why: "Session not found" only proves the provider we asked has no such PTY — and a - // degraded router answers unmapped ids from the local fallback, which never owned a - // daemon session. Retiring on that would signal exit and delete a live agent's pane - // binding. Absence must be proven across every possible owner first; `null` (nobody - // could answer) is not absence. Providers without a probe are their own sole owner, - // so their refusal stays authoritative. - if (provider.probePtyLiveness && (await provider.probePtyLiveness(owner.ptyId)) !== false) { - throw new Error('terminal_pane_owner_unverified') - } const ownerBeforeRetire = args.resolveOwner?.() if ( ownerBeforeRetire && (ownerBeforeRetire.ptyId !== owner.ptyId || - (ownerBeforeRetire.incarnationId !== undefined && - owner.incarnationId !== undefined && - ownerBeforeRetire.incarnationId !== owner.incarnationId)) + ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || + ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || + ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) ) { throw new Error('terminal_pane_owner_changed') } @@ -808,7 +845,9 @@ async function attachStablePaneOwner( if ( result.id !== owner.ptyId || result.isReattach !== true || - (owner.incarnationId !== undefined && result.incarnationId !== owner.incarnationId) + (owner.runtimeIncarnationId !== undefined && + result.incarnationId !== owner.runtimeIncarnationId) || + (result.incarnationId === undefined && owner.incarnationId !== undefined) ) { throw new Error('terminal_pane_owner_changed') } @@ -2477,7 +2516,7 @@ export function registerPtyHandlers( function syncPtyBackgroundedDelivery(id: string, caller: string): void { const background = rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) - if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) { + if (backgroundedDeliverySyncByPty.get(id) === background) { return } const provider = tryGetProviderForPty(id) @@ -4680,6 +4719,7 @@ export function registerPtyHandlers( : null let result: PtySpawnResult let stablePaneOwner: StablePaneOwner | null = null + let stablePaneBindingPersisted = false let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false @@ -4902,6 +4942,24 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(args.preAllocatedHandle) } } + try { + stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: hostSessionBinding?.store, + owner: stablePaneOwner, + result, + worktreeId: hostSessionBinding?.worktreeId, + startupCwd: cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist runtime PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } if (result.agentSessionEnsure?.disposition === 'adopted') { const owner = result.agentSessionEnsure.owner ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) @@ -4969,7 +5027,7 @@ export function registerPtyHandlers( target: codexSelectionTarget, settings: getSettings?.() }) - if (hostSessionBinding) { + if (hostSessionBinding && !stablePaneBindingPersisted) { try { const binding = { worktreeId: hostSessionBinding.worktreeId, @@ -5625,6 +5683,7 @@ export function registerPtyHandlers( let finishTerminalInstall = (): void => {} let result: PtySpawnResult let stablePaneOwner: StablePaneOwner | null = null + let stablePaneBindingPersisted = false let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false @@ -6245,6 +6304,24 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(preAllocatedHandle) } } + try { + stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store, + owner: stablePaneOwner, + result, + worktreeId: args.worktreeId, + startupCwd: cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } spawnTiming.log(result.id, { daemon: isDaemonHostSpawn, reattach: result.isReattach ?? false @@ -6303,7 +6380,8 @@ export function registerPtyHandlers( store && typeof args.worktreeId === 'string' && typeof args.tabId === 'string' && - validatedLeafId !== null + validatedLeafId !== null && + !stablePaneBindingPersisted ) { try { const binding = { diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 2356de614b0..f2a779ee412 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -3,6 +3,7 @@ import type { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshExecOptions } from '../ssh/ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' import type { FilesystemPathFlavor } from '../../shared/types' +import { sortDirEntries } from '../../shared/file-name-sort' export type RemoteDirEntry = { name: string @@ -220,13 +221,8 @@ async function runBrowseCommand( } } - // Sort: directories first, then alphabetical - entries.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + // Sort: directories first, then natural name order (matches the Explorer) + sortDirEntries(entries) resolveOnce({ entries, resolvedPath, pathFlavor }) } diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index 168b32b48ad..c77f12104ee 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -16,6 +16,12 @@ export type WorktreeBaseCollectedChanges = { headIdentityRepoIds: string[] } +export function hasCollectedWorktreeBaseChanges(changes: WorktreeBaseCollectedChanges): boolean { + return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( + (ids) => ids.length > 0 + ) +} + type ChangeBuckets = { structureRepoIds: Set gitStatusRepoIds: Set diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 5a9f8852c84..d8f58ff8503 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -353,6 +353,44 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') }) + it('throttles repeated structural refreshes from watcher failures', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onWatchError = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60_000) + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + warn.mockRestore() + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2) + }) + + it('lets a real event reset the watcher-failure refresh cooldown', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onWatchError = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + emit(PROJECT_GIT_COMMON_DIR, [{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'config') }]) + await vi.advanceTimersByTimeAsync(300) + vi.mocked(notifyWorktreesChanged).mockClear() + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + warn.mockRestore() + + expect(notifyWorktreesChanged).toHaveBeenCalledOnce() + }) + it('keeps linked HEAD and lock metadata structural', async () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 382a21dfc08..411faae40d4 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -9,7 +9,7 @@ import { import { collectLocalWorktreeBaseChanges, collectRemoteWorktreeBaseChanges, - type WorktreeBaseCollectedChanges + hasCollectedWorktreeBaseChanges } from './worktree-base-directory-change-collector' import { clearPendingWorktreeBaseNotifications, @@ -33,6 +33,7 @@ import { updateActiveGitStatusRefBinding, type GitStatusRefBindingRequest } from './worktree-git-status-ref-watch' +import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' type ActiveWatch = WorktreeBaseWatchTarget & { mainWindow: BrowserWindow @@ -43,6 +44,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & { pendingHeadIdentityRepoIds: Set headIdentityRefresh: WorktreeHeadIdentityRefreshState gitStatusRefPaths: Set + watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown disposed: boolean } @@ -57,12 +59,6 @@ export function setWorktreeGitStatusRefWatch( return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef) } -function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean { - return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( - (ids) => ids.length > 0 - ) -} - function handleLocalWatchEvents( watch: ActiveWatch, error: Error | null, @@ -74,16 +70,19 @@ function handleLocalWatchEvents( if (error) { console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + if (watch.watcherFailureRefresh.consume()) { + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + } return } + watch.watcherFailureRefresh.reset() invalidateGitStatusRefResolutionForPaths( watch, events.map((event) => event.path), () => activeWatches.values() ) const changes = collectLocalWorktreeBaseChanges(watch, events) - if (hasCollectedChanges(changes)) { + if (hasCollectedWorktreeBaseChanges(changes)) { scheduleWorktreeBaseNotification(watch, changes) } } @@ -108,7 +107,7 @@ function handleRemoteWatchEvents( scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) return } - if (hasCollectedChanges(changes)) { + if (hasCollectedWorktreeBaseChanges(changes)) { scheduleWorktreeBaseNotification(watch, changes) } } @@ -129,6 +128,7 @@ function createActiveWatch( pendingHeadIdentityRepoIds: new Set(), headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), gitStatusRefPaths, + watcherFailureRefresh: new WorktreeWatcherFailureRefreshCooldown(), disposed: false } } diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index db9ebc1d5e8..5baf86a1682 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -5,6 +5,7 @@ import { chmodSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' import type { WatcherProcessCallback, WatcherProcessHooks @@ -273,6 +274,75 @@ describe('worktree git-common narrow watch (darwin)', () => { expect(received).toEqual([]) }) + it('falls back to structural polling after the watcher crash fuse opens', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + visibility.source + ) + + childSubscriptions[0].callback( + new WatcherProcessFailure( + 'watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ), + [] + ) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledOnce() + expect(visibility.listenerCount()).toBe(3) + }) + + const entryPath = join(worktreesDir, 'fallback-entry') + await mkdir(entryPath) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + }) + await rm(entryPath, { recursive: true }) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'delete', path: entryPath }) + }) + expect(subscribeMock).toHaveBeenCalledOnce() + await watch.unsubscribe() + expect(visibility.listenerCount()).toBe(0) + }) + + it('starts structural polling when the watcher process is already unavailable', async () => { + subscribeMock.mockRejectedValue( + new WatcherProcessFailure('watcher process unavailable', 'supervisor', 'process_unavailable') + ) + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + visibility.source + ) + + const entryPath = join(worktreesDir, 'fallback-entry') + await mkdir(entryPath) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + }) + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + expect(subscribeMock).toHaveBeenCalledOnce() + + await watch.unsubscribe() + expect(visibility.listenerCount()).toBe(0) + }) + it('reports a structural change after a watcher-child interruption', async () => { installSubscribeMock() const commonDir = await makeCommonDir(true) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index b60278f76b7..e747b941015 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -1,6 +1,7 @@ import { stat } from 'node:fs/promises' import { join } from 'node:path' import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import { isWatcherProcessFailure } from './parcel-watcher-process-failure' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, @@ -30,12 +31,14 @@ async function startGitCommonNarrowWatch( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, + onFullScan?: () => void, onWatchError?: (error: Error) => void ): Promise { const worktreesDir = join(target.path, 'worktrees') let disposed = false let subscription: WorktreeBaseSubscription | null = null let existenceTimer: ReturnType | null = null + let pollingFallbackPromise: Promise | null = null let subscribing = false let parkedWhileHidden = false @@ -46,6 +49,38 @@ async function startGitCommonNarrowWatch( } } + const shouldUsePollingFallback = (error: unknown): boolean => + isWatcherProcessFailure(error) && + (error.code === 'supervisor_crash_fuse' || error.code === 'process_unavailable') + + const ensurePollingFallback = (): Promise => { + if (pollingFallbackPromise) { + return pollingFallbackPromise + } + stopExistencePoll() + const pending = startGitCommonPolling( + target.path, + onEvents, + pollIntervalMs, + visibility, + onFullScan, + false + ).then(async (fallback) => { + if (disposed || subscription) { + await fallback.unsubscribe() + return + } + subscription = fallback + }) + const tracked = pending.finally(() => { + if (pollingFallbackPromise === tracked) { + pollingFallbackPromise = null + } + }) + pollingFallbackPromise = tracked + return pollingFallbackPromise + } + const tryUpgradeToNarrowWatch = async (): Promise => { if (disposed || subscribing || subscription) { return @@ -116,7 +151,7 @@ async function startGitCommonNarrowWatch( // sometimes surfaced as an error, sometimes as a delete event for the // root. Either way: notify, drop the dead stream, and let the existence // poll re-arm when a future worktree add recreates the dir. - const teardownAndRearm = (): void => { + const teardown = (): void => { active = false errored = true const current = subscription @@ -124,6 +159,9 @@ async function startGitCommonNarrowWatch( if (current) { void current.unsubscribe().catch(() => {}) } + } + const teardownAndRearm = (): void => { + teardown() armExistencePoll() } try { @@ -139,7 +177,16 @@ async function startGitCommonNarrowWatch( } else { onEvents([{ type: 'update', path: worktreesDir }]) } - teardownAndRearm() + if (shouldUsePollingFallback(error)) { + teardown() + void ensurePollingFallback().catch(() => { + if (!disposed) { + armExistencePoll() + } + }) + } else { + teardownAndRearm() + } return } if (events.length > 0) { @@ -169,11 +216,16 @@ async function startGitCommonNarrowWatch( ) if (disposed || errored) { void sub.unsubscribe().catch(() => {}) - return !errored + await pollingFallbackPromise?.catch(() => {}) + return !errored || subscription !== null } subscription = { unsubscribe: () => sub.unsubscribe() } return true - } catch { + } catch (error) { + if (shouldUsePollingFallback(error)) { + await ensurePollingFallback() + return subscription !== null + } return false } } @@ -189,6 +241,7 @@ async function startGitCommonNarrowWatch( disposed = true stopExistencePoll() unsubscribeVisibility() + await pollingFallbackPromise?.catch(() => {}) const current = subscription subscription = null if (current) { @@ -210,7 +263,14 @@ export async function startGitCommonWatch( ): Promise { if (platform === 'darwin') { const [narrowWatch, primaryMetadataPoll] = await Promise.all([ - startGitCommonNarrowWatch(target, onEvents, pollIntervalMs, visibility, onWatchError), + startGitCommonNarrowWatch( + target, + onEvents, + pollIntervalMs, + visibility, + onFullScan, + onWatchError + ), startGitCommonPrimaryPolling( target.path, getStatusRefPaths, diff --git a/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts b/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts new file mode 100644 index 00000000000..76c33a621fa --- /dev/null +++ b/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts @@ -0,0 +1,17 @@ +const WATCHER_FAILURE_REFRESH_COOLDOWN_MS = 60_000 + +export class WorktreeWatcherFailureRefreshCooldown { + private refreshedAt: number | null = null + + consume(now = Date.now()): boolean { + if (this.refreshedAt !== null && now - this.refreshedAt < WATCHER_FAILURE_REFRESH_COOLDOWN_MS) { + return false + } + this.refreshedAt = now + return true + } + + reset(): void { + this.refreshedAt = null + } +} diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index c83eece63b6..84e9e1fbaae 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform @@ -2589,6 +2589,250 @@ describe('registerWorktreeHandlers', () => { }) }) + it('lists every persisted SSH worktree without accessing the live provider', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const metaById = { + 'repo-1::/remote/repo': makeWorktreeMeta({ + displayName: 'main', + hostId: sshHostId + }), + 'repo-1::/remote/queued': makeWorktreeMeta({ + displayName: 'queued', + hostId: sshHostId + }), + 'repo-1::/remote/other-host': makeWorktreeMeta({ + displayName: 'other host', + hostId: toSshExecutionHostId('target-b') + }) + } + store.getRepos.mockReturnValue([sshRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toMatchObject({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: sshHostId, + result: { + repoId: sshRepo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [ + expect.objectContaining({ path: '/remote/repo', isMainWorktree: true }), + expect.objectContaining({ path: '/remote/queued', isMainWorktree: false }) + ] + } + }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('lists SSH folder workspaces at the folder path, not the instance-suffixed id', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const folderRepo = { + id: 'repo-1', + path: '/remote/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a', + kind: 'folder' as const + } + const rootId = `${folderRepo.id}::${folderRepo.path}` + const instanceId = `${rootId}::workspace:11111111-2222-3333-4444-555555555555` + const metaById = { + [rootId]: makeWorktreeMeta({ displayName: 'root', hostId: sshHostId }), + [instanceId]: makeWorktreeMeta({ + displayName: 'second workspace', + hostId: sshHostId, + instanceId: '11111111-2222-3333-4444-555555555555' + }) + } + store.getRepos.mockReturnValue([folderRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = (await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: folderRepo.id, + executionHostId: sshHostId + })) as { status: string; result: { authoritative: boolean; worktrees: Worktree[] } } + + expect(result.status).toBe('complete') + expect(result.result.authoritative).toBe(false) + // Why: the git-worktree synthesizer would read the "::workspace:" tail as a directory. + expect(result.result.worktrees).toEqual([ + expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }), + expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false }) + ]) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('stops listing SSH worktrees once an authoritative scan retires their metadata', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const metaById: Record> = { + 'repo-1::/remote/repo': makeWorktreeMeta({ displayName: 'main', hostId: sshHostId }), + 'repo-1::/remote/deleted': makeWorktreeMeta({ displayName: 'deleted', hostId: sshHostId }), + 'repo-1::/remote/other-host': makeWorktreeMeta({ + displayName: 'other host', + hostId: toSshExecutionHostId('target-b') + }), + 'repo-2::/remote/other-repo': makeWorktreeMeta({ displayName: 'other repo' }) + } + store.getRepos.mockReturnValue([sshRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + store.removeWorktreeMeta.mockImplementation((worktreeId: string) => { + delete metaById[worktreeId] + }) + + const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId, + // Only the first is this host's row; the rest must survive an over-broad request. + worktreeIds: [ + 'repo-1::/remote/deleted', + 'repo-1::/remote/other-host', + 'repo-2::/remote/other-repo', + 'repo-1::/remote/never-persisted' + ] + }) + + expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/remote/deleted'] }) + expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/remote/deleted', sshHostId) + + // Why: the renderer's suppression memory is session-scoped, so the next launch must not re-list the row. + const relisted = (await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + })) as { result: { worktrees: { path: string }[] } } + + expect(relisted.result.worktrees.map((worktree) => worktree.path)).toEqual(['/remote/repo']) + }) + + it('never retires folder workspace metadata, which is the workspace record itself', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const folderRepo = { + id: 'repo-1', + path: '/remote/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a', + kind: 'folder' as const + } + const instanceId = `${folderRepo.id}::${folderRepo.path}::workspace:11111111-2222-3333-4444-555555555555` + store.getRepos.mockReturnValue([folderRepo]) + store.getAllWorktreeMeta.mockReturnValue({ + [instanceId]: makeWorktreeMeta({ displayName: 'second workspace', hostId: sshHostId }) + }) + + const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: folderRepo.id, + executionHostId: sshHostId, + worktreeIds: [instanceId] + }) + + expect(forgotten).toEqual({ forgottenWorktreeIds: [] }) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('refuses to retire metadata for non-SSH hosts and unowned repos', async () => { + const sshRepo = { + id: 'repo-1', + path: '/remote/repo-a', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }]) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/remote/deleted': makeWorktreeMeta({}) + }) + + expect( + await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: LOCAL_EXECUTION_HOST_ID, + worktreeIds: ['repo-1::/remote/deleted'] + }) + ).toEqual({ forgottenWorktreeIds: [] }) + // Ambiguous SSH ownership fails closed the same way the metadata read does. + expect( + await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + worktreeIds: ['repo-1::/remote/deleted'] + }) + ).toEqual({ forgottenWorktreeIds: [] }) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('rejects metadata-only reads for non-SSH execution hosts', async () => { + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + expect(store.getRepos).not.toHaveBeenCalled() + }) + + it('rejects ambiguous metadata-only SSH owners', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo-a', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: sshRepo.id, + executionHostId: sshHostId + }) + expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { const sshRepo = { id: 'shared-repo', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c8dceec6bed..457111cb652 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -49,7 +49,11 @@ import { import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, type DirectSshDetectedWorktreeRequest, + type ForgetRemovedWorktreesForExecutionHostArgs, + type ForgetRemovedWorktreesForExecutionHostResult, + type HostQualifiedKnownWorktreeResult, type HostQualifiedDetectedWorktreeResult, + type ListKnownWorktreesForExecutionHostArgs, type ListDetectedWorktreesArgs, type ProviderRequestId } from '../../shared/detected-worktree-provider-contract' @@ -229,7 +233,8 @@ import { import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId } from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { @@ -804,6 +809,17 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt return index } +// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole +// store, so this is cheaper per call than the unfiltered index, not free. +function createSshWorktreeMetaIndexForRepo( + allMeta: Record, + repoId: string +): SshWorktreeMetaIndex { + return createSshWorktreeMetaIndex( + Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) + ) +} + function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { return { path, @@ -842,10 +858,15 @@ function listDisconnectedSshWorktrees( if (Object.keys(ownershipUpdates).length > 0) { store.setWorktreeMeta(candidate.id, ownershipUpdates) } + // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() + // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must + // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic + // basename instead. const worktree = mergeWorktree( repo.id, synthesizeSshGitWorktree(repo, candidate.path, meta), - meta + meta, + getWorktreePathBasenameFromId(candidate.id) ?? undefined ) byWorktreeId.delete(worktree.id) byWorktreeId.set(worktree.id, worktree) @@ -1826,6 +1847,8 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') + ipcMain.removeHandler('worktrees:listKnownForExecutionHost') + ipcMain.removeHandler('worktrees:forgetRemovedForExecutionHost') ipcMain.removeHandler('worktrees:cancelListDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:prefetchCreateBase') @@ -1970,6 +1993,111 @@ export function registerWorktreeHandlers( } }) + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — + // it owns no repo, so every guard below still rejects it. + const requestedRepoId = args?.repoId ?? '' + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: requestedRepoId, + executionHostId: requestedExecutionHostId + }) + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the + // whole catalog, so a separate pass here is pure cost. + const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ + status: 'complete', + repoId: repo.id, + executionHostId: requestedExecutionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees + } + }) + // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. + if (isFolderRepo(repo)) { + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) + ? rejected() + : complete( + // Why: match the authoritative folder listing; without lineage these rows render flat and then + // reshuffle once the real scan lands. + projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + ) + } + const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + return complete( + buildDisconnectedDetectedWorktrees( + store, + repo, + listDisconnectedSshWorktrees(store, repo, metaIndex) + ) + ) + } + ) + + // Why: gcStaleWorktreeMeta cannot stat a remote path, so SSH metadata outlives the worktree and the fallback + // above re-lists a worktree deleted outside Orca on every launch. An authoritative scan is the only proof of + // absence, so the renderer reports what it retired here and the row is dropped like a local GC would. + ipcMain.handle( + 'worktrees:forgetRemovedForExecutionHost', + ( + _event, + args: ForgetRemovedWorktreesForExecutionHostArgs + ): ForgetRemovedWorktreesForExecutionHostResult => { + const nothingForgotten: ForgetRemovedWorktreesForExecutionHostResult = { + forgottenWorktreeIds: [] + } + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { + return nothingForgotten + } + const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return nothingForgotten + } + // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips + // those keys for the same reason, and no remote scan can retire one. + if (isFolderRepo(repo)) { + return nothingForgotten + } + const allMeta = store.getAllWorktreeMeta() + const forgottenWorktreeIds: string[] = [] + for (const worktreeId of worktreeIds) { + const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined + if (!meta || getRepoIdFromWorktreeId(worktreeId) !== repo.id) { + continue + } + // An unhosted meta belongs to this repo's only owner; a foreign hostId needs that host's own scan. + if (meta.hostId && meta.hostId !== requestedExecutionHostId) { + continue + } + store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) + forgottenWorktreeIds.push(worktreeId) + } + return { forgottenWorktreeIds } + } + ) + ipcMain.handle( 'worktrees:listDetected', async ( diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 8433fd93e8a..e771cae9090 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -1,9 +1,15 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { buildFromTemplateMock, setApplicationMenuMock, getFocusedWindowMock } = vi.hoisted(() => ({ +const { + buildFromTemplateMock, + setApplicationMenuMock, + getFocusedWindowMock, + sendActionToFirstResponderMock +} = vi.hoisted(() => ({ buildFromTemplateMock: vi.fn(), setApplicationMenuMock: vi.fn(), - getFocusedWindowMock: vi.fn() + getFocusedWindowMock: vi.fn(), + sendActionToFirstResponderMock: vi.fn() })) vi.mock('electron', () => ({ @@ -12,7 +18,8 @@ vi.mock('electron', () => ({ }, Menu: { buildFromTemplate: buildFromTemplateMock, - setApplicationMenu: setApplicationMenuMock + setApplicationMenu: setApplicationMenuMock, + sendActionToFirstResponder: sendActionToFirstResponderMock }, app: { name: 'Orca' @@ -70,9 +77,14 @@ describe('registerAppMenu', () => { buildFromTemplateMock.mockReset() setApplicationMenuMock.mockReset() getFocusedWindowMock.mockReset() + sendActionToFirstResponderMock.mockReset() buildFromTemplateMock.mockImplementation((template) => ({ template })) }) + afterEach(() => { + vi.restoreAllMocks() + }) + it('shows reload shortcuts as policy-routed menu hints', () => { registerAppMenu(buildMenuOptions()) @@ -214,23 +226,59 @@ describe('registerAppMenu', () => { expect(paletteItem?.accelerator).toBeUndefined() }) - it('routes Edit > Paste through Orca coordinated paste ownership', () => { - const send = vi.fn() - getFocusedWindowMock.mockReturnValue({ webContents: { send } }) + // Why: pin the platform on every case — CI runs this suite on Linux only, so an + // unpinned test leaves the other platforms' branches entirely uncovered. + it.each(['darwin', 'linux', 'win32'] as const)( + 'routes Edit > Paste through Orca coordinated paste ownership on %s', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const send = vi.fn() + getFocusedWindowMock.mockReturnValue({ webContents: { send } }) + registerAppMenu(buildMenuOptions()) + + const editSubmenu = getSubmenu(getTemplate(), 'Edit') + const pasteItem = editSubmenu.find((item) => item.label === 'Paste') + + expect(pasteItem).toBeDefined() + expect(pasteItem?.role).toBeUndefined() + expect(pasteItem?.accelerator).toBe('CmdOrCtrl+V') + + pasteItem?.click?.({} as never, {} as never, {} as never) + + expect(send).toHaveBeenCalledOnce() + expect(send).toHaveBeenCalledWith('ui:appMenuPaste') + expect(sendActionToFirstResponderMock).not.toHaveBeenCalled() + } + ) + + it('routes Edit > Paste to the native first responder once on macOS without a focused window', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + getFocusedWindowMock.mockReturnValue(null) registerAppMenu(buildMenuOptions()) - const editSubmenu = getSubmenu(getTemplate(), 'Edit') - const pasteItem = editSubmenu.find((item) => item.label === 'Paste') - - expect(pasteItem).toBeDefined() - expect(pasteItem?.role).toBeUndefined() - expect(pasteItem?.accelerator).toBe('CmdOrCtrl+V') - + const pasteItem = getSubmenu(getTemplate(), 'Edit').find((item) => item.label === 'Paste') pasteItem?.click?.({} as never, {} as never, {} as never) - expect(send).toHaveBeenCalledWith('ui:appMenuPaste') + expect(sendActionToFirstResponderMock).toHaveBeenCalledOnce() + expect(sendActionToFirstResponderMock).toHaveBeenCalledWith('paste:') }) + it.each(['linux', 'win32'] as const)( + 'does not invoke the native paste responder on %s without a focused window', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + getFocusedWindowMock.mockReturnValue(null) + registerAppMenu(buildMenuOptions()) + + const pasteItem = getSubmenu(getTemplate(), 'Edit').find((item) => item.label === 'Paste') + // Why: this case asserts only a negative, so it would pass green if the item vanished. + expect(pasteItem).toBeDefined() + pasteItem?.click?.({} as never, {} as never, {} as never) + + expect(sendActionToFirstResponderMock).not.toHaveBeenCalled() + } + ) + it.runIf(!isMac)('puts Settings and Exit under File on Windows/Linux', () => { registerAppMenu(buildMenuOptions()) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index d1e43d4fb82..3bca33268f9 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -180,7 +180,17 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { click: () => { // Why: a focused terminal/native-chat pane is not a native editable // control, so raw Electron paste cannot know which Orca surface owns it. - BrowserWindow.getFocusedWindow()?.webContents.send('ui:appMenuPaste') + const focusedWindow = BrowserWindow.getFocusedWindow() + if (focusedWindow) { + focusedWindow.webContents.send('ui:appMenuPaste') + return + } + + // Why: a macOS native panel (open/save, Go to Folder) leaves no focused + // BrowserWindow, so overriding the paste role would strand Cmd+V as a no-op. + if (isMac) { + Menu.sendActionToFirstResponder('paste:') + } } }, { role: 'selectAll' } diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 85cd653185b..98aa1f4abad 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -137,6 +137,58 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('matches omp transcripts by session id suffix inside the per-cwd directory', async () => { + const root = await makeRoot('orca-native-chat-resolve-omp-') + const ompSessionsDir = join(root, 'omp-sessions') + const cwdDir = join(ompSessionsDir, '-Users-ada-repo') + await mkdir(cwdDir, { recursive: true }) + const target = join(cwdDir, '2026-07-16T00-27-02-222Z_sess-omp-1.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('omp', 'sess-omp-1', { ompSessionsDir }) + expect(resolved).toBe(target) + }) + + it('never descends into an omp session artifact dir', async () => { + // Why: a session's task-subagent transcripts sit in its same-named + // `_/` artifact dir, and a label-named child CAN end in + // `_`. Asserting the parent wins would only prove the prune on a + // filesystem that happens to enumerate the dir first, so give the id exactly + // one match — inside the artifact dir. Pruned resolves to null; descending + // finds the child, whatever order readdir returns. + const root = await makeRoot('orca-native-chat-resolve-omp-artifact-') + const ompSessionsDir = join(root, 'omp-sessions') + const cwdDir = join(ompSessionsDir, '-Users-ada-repo') + const stem = '2026-07-16T00-27-02-222Z_019fd8e2-fd56-7000-acfe-2e497adfa83c' + await mkdir(join(cwdDir, stem), { recursive: true }) + await writeFile(join(cwdDir, `${stem}.jsonl`), '{}\n') + await writeFile(join(cwdDir, stem, 'worker_sess-omp-child.jsonl'), '{}\n') + + await expect( + resolveSessionFilePath('omp', 'sess-omp-child', { ompSessionsDir }) + ).resolves.toBeNull() + // The parent transcript itself still resolves through the pruned walk. + await expect( + resolveSessionFilePath('omp', '019fd8e2-fd56-7000-acfe-2e497adfa83c', { ompSessionsDir }) + ).resolves.toBe(join(cwdDir, `${stem}.jsonl`)) + }) + + it('honors OMP_CODING_AGENT_DIR when resolving omp transcripts', async () => { + const root = await makeRoot('orca-native-chat-resolve-omp-env-') + const cwdDir = join(root, 'omp-sessions', '-Users-ada-repo') + await mkdir(cwdDir, { recursive: true }) + const target = join(cwdDir, '2026-07-16T00-27-02-222Z_sess-omp-env.jsonl') + await writeFile(target, '{}\n') + + const previous = process.env.OMP_CODING_AGENT_DIR + process.env.OMP_CODING_AGENT_DIR = join(root, 'omp-sessions') + try { + await expect(resolveSessionFilePath('omp', 'sess-omp-env')).resolves.toBe(target) + } finally { + restoreEnv('OMP_CODING_AGENT_DIR', previous) + } + }) + it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => { // Orca launches Codex with its own managed CODEX_HOME, so rollout files land // under /codex-runtime-home/home/sessions, NOT ~/.codex/sessions. diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 33cf8c23823..136e3ea2291 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -4,6 +4,8 @@ import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts' +import { normalizeAgentSessionsDir } from '../ai-vault/session-scanner-values' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { findGrokChatHistoryBySessionId, @@ -37,6 +39,15 @@ function grokSessionsDir(): string { return resolveGrokSessionsDir(process.env, homedir()) } +/** Mirrors the AI Vault scanner so an OMP_CODING_AGENT_DIR override resolves the + * same root for both, rather than leaving native chat pointed at the default. */ +function ompSessionsDir(): string { + return normalizeAgentSessionsDir( + process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'), + '.omp' + ) +} + export type ResolveSessionFileOptions = { /** Override the Claude projects root (used by tests / isolated scans). */ claudeProjectsDir?: string @@ -45,6 +56,8 @@ export type ResolveSessionFileOptions = { codexSessionsDirs?: string[] /** Override the Grok sessions root (`~/.grok/sessions`). */ grokSessionsDir?: string + /** Override the omp sessions root (`~/.omp/agent/sessions`). */ + ompSessionsDir?: string /** Authoritative transcript path reported by the agent hook * (`providerSession.transcriptPath`). When set and the file exists, it is used * directly — recent Claude Code names the transcript with a UUID that differs @@ -94,6 +107,9 @@ export async function resolveSessionFilePath( if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) } + if (transcriptAgent === 'omp') { + return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir()) + } return null } @@ -143,3 +159,30 @@ async function resolveGrokSessionFile( const history = await findGrokChatHistoryBySessionId(sessionsDir, sessionId) return history } + +// omp keeps one directory per working directory (`-Documents-dog-app`) with the +// transcript inside it, named `_.jsonl` — so match the +// id as a base-name suffix, the way Codex rollout files are matched, and let the +// walk cover the per-cwd subdirectories. +async function resolveOmpSessionFile( + sessionId: string, + sessionsDir: string +): Promise { + const files = await walkSessionFiles(sessionsDir, 'omp', [], { + extensions: new Set(['.jsonl']), + // Why: a session's task-subagent transcripts live in its same-named + // `_/` artifact dir, and a label-named child can still end in + // `_` — so descending would let a subagent transcript win the + // suffix match over its own parent. Prune the subtree exactly as the AI + // Vault scanner does (session-scanner-source-discovery.ts): it keeps the + // walk at one readdir per workspace dir regardless of how much the session + // delegated. Depth 0 is the workspace dir, which is never an artifact dir. + directoryPredicate: (name, depth) => + depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name), + filePredicate: (path) => { + const name = basename(path, extname(path)) + return name === sessionId || name.endsWith(`_${sessionId}`) + } + }) + return files[0] ?? null +} diff --git a/src/main/native-chat/transcript-line-decoders-omp.ts b/src/main/native-chat/transcript-line-decoders-omp.ts new file mode 100644 index 00000000000..60fcd736f35 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-omp.ts @@ -0,0 +1,227 @@ +// omp (pi-agent) JSONL line → NativeChatMessage decoder. +// +// Conversation turns are `type: 'message'`; every other record type is session +// bookkeeping. Rendering follows file order, so `parentId` is not consulted: +// omp's file is really a tree and its own TUI renders pathTo(leaf), so a session +// rewound onto a new branch shows the abandoned turns here too. That matches the +// Claude decoder, which ignores `parentUuid` the same way; unwinding the branch +// needs a stateful decoder contract shared by every agent, not an omp-only fix. + +import { + NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, + type NativeChatBlock, + type NativeChatMessage +} from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { toolResultOutput } from './transcript-record-blocks' + +/** + * omp session rows: `type: 'message'` turns carrying user/assistant/toolResult/ + * developer records with text, thinking, toolCall and image content blocks, the + * content-less bash/python execution cells, plus the `type: 'custom_message'` + * rows extensions inject into the conversation. + * Session bookkeeping rows (session_init, mode_change, compaction, custom) are + * skipped, as are records of an unrecognized type. + */ +export function decodeOmpTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record || (record.type !== 'message' && record.type !== 'custom_message')) { + return null + } + const id = extractString(record.id) ?? fallbackId + const timestamp = parseTimestamp(record.timestamp) + + if (record.type === 'custom_message') { + // Why: these extension-authored turns reach the model, and omp's own + // transcript renders them — but only when `display` is set; the rest are + // extension state it never shows (CustomMessageEntry, session-entries.d.ts). + const customBlocks = record.display === true ? ompContentBlocks(record.content) : [] + return customBlocks.length === 0 + ? null + : { id, role: 'system', blocks: customBlocks, timestamp, source: 'transcript' } + } + + const message = asRecord(record.message) + if (!message) { + return null + } + const role = extractString(message.role) + + if (role === 'toolResult') { + return { + id, + role: 'tool', + blocks: [ + { + type: 'tool-result', + output: toolResultOutput(message.content), + ...(message.isError === true ? { isError: true } : {}) + } + ], + timestamp, + source: 'transcript' + } + } + + if (role === 'bashExecution' || role === 'pythonExecution') { + // Why: omp persists a TUI `!command` / python run as a content-less message + // ({role, command|code, output, exitCode}) and renders it as a command cell. + // Surface it as a tool turn so the output keeps its collapsible affordance. + return { + id, + role: 'tool', + blocks: ompExecutionBlocks(role, message), + timestamp, + source: 'transcript' + } + } + + if (role === 'fileMention') { + // Why: an `@path` attachment is another content-less record omp's transcript + // renders. List the paths only — `files[].content` is an auto-read dump that + // would bury the conversation. + const paths = ompFileMentionPaths(message.files) + return paths.length === 0 + ? null + : { + id, + role: 'system', + blocks: [{ type: 'text', text: paths.map((path) => `@${path}`).join('\n') }], + timestamp, + source: 'transcript' + } + } + + // Why: sessions written before version 3 stored extension turns as + // `type: 'message'` with role custom/hookMessage and a message-level + // `display` flag (the v3 migration rewrites hookMessage -> custom). Honor the + // same gate the `custom_message` branch does, or a resumed legacy session + // renders state omp itself hides. + if ((role === 'custom' || role === 'hookMessage') && message.display !== true) { + return null + } + const blocks = ompContentBlocks(message.content) + if (blocks.length === 0) { + // Why: omp stamps an aborted turn onto the assistant message itself + // (`stopReason: 'aborted'`), and when nothing streamed before the abort the + // content is empty — so the turn would silently vanish. Surface it as the + // same interrupted row Claude and Codex emit for their own aborts. + return role === 'assistant' && message.stopReason === 'aborted' + ? { + id, + role: 'system', + blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], + timestamp, + source: 'transcript' + } + : null + } + const messageRole = role === 'assistant' ? 'assistant' : role === 'user' ? 'user' : 'system' + return { id, role: messageRole, blocks, timestamp, source: 'transcript' } +} + +/** A bash/python execution cell: the invocation, then its captured output. */ +function ompExecutionBlocks( + role: 'bashExecution' | 'pythonExecution', + message: Record +): NativeChatBlock[] { + const isBash = role === 'bashExecution' + // Why: read these raw rather than via `extractString` — it trims, and leading + // or trailing whitespace is meaningful in captured command output. + const source = isBash ? message.command : message.code + const output = message.output + // Why: every cancel/timeout path returns `{exitCode: undefined, cancelled: true}`, + // and JSON.stringify drops the undefined key, so a cancelled run carries NO + // exitCode on disk. Without the `cancelled` arm it would render as a clean + // success next to partial output; omp's own cell shows a cancelled marker. + const failed = + message.cancelled === true || (typeof message.exitCode === 'number' && message.exitCode !== 0) + return [ + { + type: 'tool-call', + name: isBash ? 'bash' : 'python', + input: typeof source === 'string' ? source : '' + }, + { + type: 'tool-result', + output: typeof output === 'string' ? output : '', + ...(failed ? { isError: true } : {}) + } + ] +} + +/** The `path` of every entry in a fileMention's `files` array. */ +function ompFileMentionPaths(files: unknown): string[] { + if (!Array.isArray(files)) { + return [] + } + const paths: string[] = [] + for (const file of files) { + const path = extractString(asRecord(file)?.path) + if (path) { + paths.push(path) + } + } + return paths +} + +/** Build the blocks for one omp content array. */ +function ompContentBlocks(content: unknown): NativeChatBlock[] { + if (typeof content === 'string') { + return content.trim() ? [{ type: 'text', text: content }] : [] + } + if (!Array.isArray(content)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const item of content) { + const block = ompContentBlock(asRecord(item)) + if (block) { + blocks.push(block) + } + } + return blocks +} + +/** Map one omp content entry; unknown block types yield null and are dropped. */ +function ompContentBlock(record: Record | null): NativeChatBlock | null { + if (!record) { + return null + } + switch (record.type) { + case 'text': { + const text = extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'thinking': { + const text = extractString(record.thinking) ?? extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'toolCall': { + const name = extractString(record.name) ?? 'tool' + return { type: 'tool-call', name, input: record.arguments } + } + case 'image': + // Why: omp stores images as content-addressed blob handles + // (`blob:sha256:…`) rather than a path or URL, so there is nothing the + // renderer can load. Dropping the block matches how the Claude mapper + // treats an image record with neither source. + return null + default: + return null + } +} + +/** `timestampMs` yields NaN for an unparsable value; the chat model wants null. */ +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders.omp.test.ts b/src/main/native-chat/transcript-line-decoders.omp.test.ts new file mode 100644 index 00000000000..04e28dfbc22 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.omp.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest' +import { decodeOmpTranscriptLine } from './transcript-line-decoders' + +const line = (record: unknown): string => JSON.stringify(record) + +const message = (role: string, content: unknown, extra: Record = {}): string => + line({ + type: 'message', + id: 'rec-1', + parentId: 'rec-0', + timestamp: '2026-07-16T00:27:02.222Z', + message: { role, content, ...extra } + }) + +describe('decodeOmpTranscriptLine', () => { + it('skips malformed lines and non-conversation records', () => { + expect(decodeOmpTranscriptLine('not json', 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'session_init', id: 'a' }), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'mode_change', id: 'a' }), 'f')).toBeNull() + expect( + decodeOmpTranscriptLine(line({ type: 'custom', customType: 'tool_execution_start' }), 'f') + ).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'compaction', shortSummary: 's' }), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'a-type-from-the-future' }), 'f')).toBeNull() + }) + + it('decodes a user turn', () => { + const decoded = decodeOmpTranscriptLine( + message('user', [{ type: 'text', text: 'resume' }]), + 'f' + ) + expect(decoded).toEqual({ + id: 'rec-1', + role: 'user', + blocks: [{ type: 'text', text: 'resume' }], + timestamp: Date.parse('2026-07-16T00:27:02.222Z'), + source: 'transcript' + }) + }) + + it('keeps thinking and tool calls together on a mixed assistant turn', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [ + { type: 'thinking', thinking: 'Checking the goal' }, + { type: 'text', text: 'Reading it now.' }, + { type: 'toolCall', id: 'call-1', name: 'goal', arguments: { op: 'get' } } + ]), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([ + { type: 'text', text: 'Checking the goal' }, + { type: 'text', text: 'Reading it now.' }, + { type: 'tool-call', name: 'goal', input: { op: 'get' } } + ]) + }) + + it('keeps a thinking-only assistant turn on the assistant role', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'thinking', thinking: 'Weighing two options' }]), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Weighing two options' }]) + }) + + it('passes tool arguments through unchanged', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [ + { type: 'toolCall', name: 'goal', arguments: { op: 'get', objective: null } } + ]), + 'f' + ) + expect(decoded?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'goal', + input: { op: 'get', objective: null } + }) + }) + + it('decodes a tool result', () => { + const decoded = decodeOmpTranscriptLine( + message('toolResult', [{ type: 'text', text: 'ok' }], { + toolCallId: 'call-1', + toolName: 'goal', + isError: false + }), + 'f' + ) + expect(decoded?.role).toBe('tool') + expect(decoded?.blocks).toEqual([{ type: 'tool-result', output: 'ok' }]) + }) + + it('flags an errored tool result', () => { + const decoded = decodeOmpTranscriptLine( + message('toolResult', [{ type: 'text', text: 'boom' }], { + toolCallId: 'call-2', + isError: true + }), + 'f' + ) + expect(decoded?.blocks[0]).toEqual({ type: 'tool-result', output: 'boom', isError: true }) + }) + + it('surfaces a displayed custom_message, and hides a state-only one', () => { + const custom = (display: boolean): string => + line({ + type: 'custom_message', + id: 'rec-c', + customType: 'rewind-report', + display, + content: [{ type: 'text', text: 'Investigation summary' }], + timestamp: '2026-07-16T00:27:02.222Z' + }) + expect(decodeOmpTranscriptLine(custom(true), 'f')).toEqual({ + id: 'rec-c', + role: 'system', + blocks: [{ type: 'text', text: 'Investigation summary' }], + timestamp: Date.parse('2026-07-16T00:27:02.222Z'), + source: 'transcript' + }) + expect(decodeOmpTranscriptLine(custom(false), 'f')).toBeNull() + }) + + it('accepts string content on a custom_message', () => { + const decoded = decodeOmpTranscriptLine( + line({ type: 'custom_message', id: 'rec-s', display: true, content: 'peer said hi' }), + 'f' + ) + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'peer said hi' }]) + }) + + it('renders a bash execution cell as a tool turn', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-b', + timestamp: '2026-07-16T00:27:02.222Z', + message: { role: 'bashExecution', command: 'ls -a', output: '.git\n', exitCode: 0 } + }), + 'f' + ) + expect(decoded?.role).toBe('tool') + expect(decoded?.blocks).toEqual([ + { type: 'tool-call', name: 'bash', input: 'ls -a' }, + { type: 'tool-result', output: '.git\n' } + ]) + }) + + it('flags a nonzero exit code and reads python cells from `code`', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-p', + message: { + role: 'pythonExecution', + code: 'raise SystemExit(2)', + output: 'boom', + exitCode: 2 + } + }), + 'f' + ) + expect(decoded?.blocks).toEqual([ + { type: 'tool-call', name: 'python', input: 'raise SystemExit(2)' }, + { type: 'tool-result', output: 'boom', isError: true } + ]) + }) + + // Why: omp's cancel and timeout paths both return `exitCode: undefined`, which + // JSON.stringify omits — so the record has no exitCode at all and only + // `cancelled` distinguishes it from a clean run. + it('marks a cancelled run, which carries no exitCode at all', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-c1', + message: { role: 'bashExecution', command: 'sleep 99', output: 'partial', cancelled: true } + }), + 'f' + ) + expect(decoded?.blocks[1]).toEqual({ type: 'tool-result', output: 'partial', isError: true }) + }) + + it('lists file mention paths without dumping their auto-read contents', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-f', + message: { + role: 'fileMention', + files: [ + { path: 'src/a.ts', content: 'SECRET FILE BODY' }, + { path: 'src/b.ts' }, + { image: true } + ] + } + }), + 'f' + ) + expect(decoded?.role).toBe('system') + expect(decoded?.blocks).toEqual([{ type: 'text', text: '@src/a.ts\n@src/b.ts' }]) + expect(JSON.stringify(decoded)).not.toContain('SECRET FILE BODY') + }) + + // Why: pre-v3 sessions stored extension turns as `type:'message'` with role + // custom/hookMessage; the display flag gates them exactly as custom_message. + it('honors the display gate on legacy custom / hookMessage message rows', () => { + const legacy = (role: string, display: boolean): string => + line({ + type: 'message', + id: 'rec-l', + message: { role, customType: 'irc:incoming', display, content: 'peer note' } + }) + expect(decodeOmpTranscriptLine(legacy('custom', false), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(legacy('hookMessage', false), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(legacy('custom', true), 'f')).toMatchObject({ + role: 'system', + blocks: [{ type: 'text', text: 'peer note' }] + }) + }) + + it('surfaces the developer channel as system', () => { + const decoded = decodeOmpTranscriptLine( + message('developer', [{ type: 'text', text: 'context note' }]), + 'f' + ) + expect(decoded?.role).toBe('system') + }) + + it('drops blob-handle images, which the renderer cannot load', () => { + expect( + decodeOmpTranscriptLine( + message('user', [{ type: 'image', data: 'blob:sha256:abc', mimeType: 'image/webp' }]), + 'f' + ) + ).toBeNull() + }) + + it('surfaces an aborted turn as the interrupted row, not silence', () => { + // Why: omp stamps `stopReason: 'aborted'` on the assistant message itself and + // leaves the content empty when nothing streamed, so the turn used to decode + // to null and vanish. Claude and Codex both emit this row for their aborts. + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'text', text: '' }], { + stopReason: 'aborted', + errorMessage: 'Stopped before model call' + }), + 'f' + ) + expect(decoded?.role).toBe('system') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Conversation interrupted' }]) + }) + + it('keeps partial content when a turn aborted mid-stream', () => { + // omp spreads the in-flight message and stamps the abort on it, so a partial + // answer is real conversation and must not be replaced by the status row. + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'text', text: 'Partial answer' }], { stopReason: 'aborted' }), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Partial answer' }]) + }) + + it('still drops an empty non-aborted assistant turn', () => { + expect( + decodeOmpTranscriptLine(message('assistant', [{ type: 'text', text: '' }]), 'f') + ).toBeNull() + }) + + it('falls back to the supplied id when the record carries none', () => { + const decoded = decodeOmpTranscriptLine( + line({ type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } }), + 'fallback-9' + ) + expect(decoded?.id).toBe('fallback-9') + expect(decoded?.timestamp).toBeNull() + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 67b1aaf76aa..2d84d1a5586 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -12,3 +12,4 @@ export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok' +export { decodeOmpTranscriptLine } from './transcript-line-decoders-omp' diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 86353604e20..8cb57fdb91d 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -10,7 +10,8 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, - decodeGrokTranscriptLine + decodeGrokTranscriptLine, + decodeOmpTranscriptLine } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' @@ -55,6 +56,9 @@ export async function readNativeChatTranscript( if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } + if (transcriptAgent === 'omp') { + return { messages: await readTranscript(filePath, decodeOmpTranscriptLine) } + } return { error: `Unsupported agent for Chat UI transcript: ${agent}` } } catch (err) { // Why: ENOENT after a successful resolve is the same first-flush/rotation diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 9fb66705103..35bee906956 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -9,7 +9,8 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, - decodeGrokTranscriptLine + decodeGrokTranscriptLine, + decodeOmpTranscriptLine } from './transcript-line-decoders' import { transcriptFallbackId } from './transcript-fallback-id' import { @@ -33,6 +34,9 @@ export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineD if (transcriptAgent === 'grok') { return decodeGrokTranscriptLine } + if (transcriptAgent === 'omp') { + return decodeOmpTranscriptLine + } return null } diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts new file mode 100644 index 00000000000..42d348af61c --- /dev/null +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -0,0 +1,427 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' + +type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable' + +const testState = { dir: '' } +const cipherState = { + availability: 'available' as 'available' | 'throws' | 'unavailable', + encryptionThrows: false, + decryptionThrows: false +} + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => { + if (cipherState.availability === 'throws') { + throw new Error('keychain access denied') + } + return cipherState.availability === 'available' + }, + encryptString: (plaintext: string) => { + if (cipherState.encryptionThrows) { + throw new Error('keychain encryption failed') + } + return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8') + }, + decryptString: (ciphertext: Buffer) => { + if (cipherState.decryptionThrows) { + throw new Error('keychain decryption failed') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + } + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function dataFile(): string { + return join(testState.dir, 'orca-data.json') +} + +type ProtectedState = { + settings: { + httpProxyUrl: string + httpProxyBypassRules: string + opencodeSessionCookie: string + } + ui: { browserKagiSessionLink: string | null } + sshPtyConsumerRecoveries: { ownerLease: string }[] +} + +function readState(path = dataFile()): ProtectedState { + return JSON.parse(readFileSync(path, 'utf-8')) +} + +const ORIGINAL = { + proxy: 'http://old-user:old-pass@proxy.test:8080', + cookie: 'old-opencode-cookie', + kagi: 'https://kagi.test/session/old-token', + ownerLease: `old-ssh-owner-lease-${'x'.repeat(480)}` +} as const +const PENDING = { + proxy: 'http://new-user:new-pass@proxy.test:8080', + cookie: 'new-opencode-cookie', + kagi: 'https://kagi.test/session/new-token', + ownerLease: `new-ssh-owner-lease-${'y'.repeat(480)}` +} as const + +function setFailure(mode: FailureMode): void { + cipherState.availability = + mode === 'availability-throws' ? 'throws' : mode === 'unavailable' ? 'unavailable' : 'available' + cipherState.encryptionThrows = mode === 'encryption-throws' +} + +async function writeProtectedState( + store: Awaited>, + values: typeof ORIGINAL | typeof PENDING, + bypassRules: string +): Promise { + store.updateSettings({ + httpProxyUrl: values.proxy, + httpProxyBypassRules: bypassRules, + opencodeSessionCookie: values.cookie + }) + store.updateUI({ browserKagiSessionLink: values.kagi }) + await store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: values.ownerLease + }) +} + +function expectPlaintextsAbsent(raw: string, values: typeof ORIGINAL | typeof PENDING): void { + for (const plaintext of Object.values(values)) { + expect.soft(raw).not.toContain(plaintext) + } +} + +async function settleSave(store: Awaited>): Promise { + vi.advanceTimersByTime(2_000) + await store.waitForPendingWrite() +} + +describe('protected persistence when safeStorage fails', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-safe-storage-test-')) + cipherState.availability = 'available' + cipherState.encryptionThrows = false + cipherState.decryptionThrows = false + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'omits newly introduced protected values during a persistent failure: %s', + async (failureMode) => { + setFailure(failureMode) + const store = await createStore() + await writeProtectedState(store, PENDING, 'non-secret-saved') + + const raw = readFileSync(dataFile(), 'utf-8') + const persisted = readState() + expectPlaintextsAbsent(raw, PENDING) + expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.opencodeSessionCookie).toBe('') + expect(persisted.ui.browserKagiSessionLink).toBe('') + expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + expect(persisted.settings.httpProxyBypassRules).toBe('non-secret-saved') + } + ) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'persists the pending protected state after same-instance recovery: %s', + async (failureMode) => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + setFailure(failureMode) + await writeProtectedState(store, PENDING, 'during-failure') + + cipherState.availability = 'available' + cipherState.encryptionThrows = false + await writeProtectedState(store, PENDING, 'during-failure') + + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(PENDING.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(PENDING.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(PENDING.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(PENDING.ownerLease) + } + ) + + it('honors explicit clears during an outage without later resurrecting ciphertext', async () => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + cipherState.availability = 'unavailable' + + store.updateSettings({ httpProxyUrl: '', opencodeSessionCookie: '' }) + store.updateUI({ browserKagiSessionLink: null }) + await store.removeSshPtyConsumerRecovery('ssh-1') + await settleSave(store) + + await writeProtectedState(store, PENDING, 'after-clear') + const persisted = readState() + expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.opencodeSessionCookie).toBe('') + expect(persisted.ui.browserKagiSessionLink).toBe('') + expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + + cipherState.availability = 'available' + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe('') + expect(restarted.getSettings().opencodeSessionCookie).toBe('') + expect(restarted.getUI().browserKagiSessionLink).toBe('') + expect(restarted.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + }) + + it('persists clears after a healthy save preserves sealed ciphertext', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.availability = 'unavailable' + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + + cipherState.availability = 'available' + sealed.updateSettings({ httpProxyBypassRules: 'healthy-preserve' }) + await settleSave(sealed) + expect(readState().settings.httpProxyUrl).toBe(originalCiphertext.settings.httpProxyUrl) + expect(readState().settings.opencodeSessionCookie).toBe( + originalCiphertext.settings.opencodeSessionCookie + ) + + sealed.updateSettings({ httpProxyUrl: '', opencodeSessionCookie: '' }) + await settleSave(sealed) + expect(readState().settings.httpProxyUrl).toBe('') + expect(readState().settings.opencodeSessionCookie).toBe('') + }) + + it('JSON-escapes retained legacy plaintext while storage is unavailable', async () => { + const legacyCookie = 'legacy-"cookie\\value' + writeFileSync(dataFile(), JSON.stringify({ settings: { opencodeSessionCookie: legacyCookie } })) + cipherState.availability = 'unavailable' + const store = await createStore() + + store.updateSettings({ opencodeSessionCookie: PENDING.cookie }) + await settleSave(store) + + expect(readState().settings.opencodeSessionCookie).toBe(legacyCookie) + }) + + it('evicts ciphertext when decrypted SSH recovery validation rejects the record', async () => { + const oversizedLease = 'z'.repeat(513) + const encryptedLease = Buffer.from(`enc:${randomUUID()}:${oversizedLease}`).toString('base64') + writeFileSync( + dataFile(), + JSON.stringify({ + sshPtyConsumerRecoveries: [ + { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: encryptedLease + } + ] + }) + ) + const store = await createStore() + expect(store.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + cipherState.availability = 'unavailable' + await store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 2, + ownerGeneration: 2, + ownerLease: 'replacement-owner-lease' + }) + + expect(readState().sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + }) + + it('keeps loaded ciphertext sealed through same-instance recovery', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.availability = 'unavailable' + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + expect(sealed.getUI().browserKagiSessionLink).toBe('') + expect(sealed.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + sealed.updateSettings({ httpProxyBypassRules: 'during-outage' }) + await settleSave(sealed) + expect(readState().settings.opencodeSessionCookie).toBe( + originalCiphertext.settings.opencodeSessionCookie + ) + + cipherState.availability = 'available' + sealed.updateSettings({ httpProxyBypassRules: 'after-recovery' }) + await settleSave(sealed) + expect(readState()).toMatchObject({ + settings: { + httpProxyUrl: originalCiphertext.settings.httpProxyUrl, + opencodeSessionCookie: originalCiphertext.settings.opencodeSessionCookie + }, + ui: { browserKagiSessionLink: originalCiphertext.ui.browserKagiSessionLink }, + sshPtyConsumerRecoveries: [ + { ownerLease: originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease } + ] + }) + + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + expect(restarted.getSettings().httpProxyBypassRules).toBe('after-recovery') + }) + + it('keeps undecryptable ciphertext sealed from consumers and later saves', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.decryptionThrows = true + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + expect(sealed.getUI().browserKagiSessionLink).toBe('') + expect(sealed.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + sealed.updateSettings({ httpProxyBypassRules: 'decryption-failed' }) + await settleSave(sealed) + expect(readState()).toMatchObject({ + settings: { + httpProxyUrl: originalCiphertext.settings.httpProxyUrl, + opencodeSessionCookie: originalCiphertext.settings.opencodeSessionCookie + }, + ui: { browserKagiSessionLink: originalCiphertext.ui.browserKagiSessionLink }, + sshPtyConsumerRecoveries: [ + { ownerLease: originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease } + ] + }) + + cipherState.decryptionThrows = false + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + }) + + it('accepts validated legacy plaintext for OpenCode and SSH migration', async () => { + const legacyCookie = 'auth=Fe26.2**legacy-token' + const legacyOwnerLease = '9ab3f53d-0de9-4b80-af38-0cc15f62a6ba' + writeFileSync( + dataFile(), + JSON.stringify({ + settings: { opencodeSessionCookie: legacyCookie }, + sshPtyConsumerRecoveries: [ + { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: legacyOwnerLease + } + ] + }) + ) + + const store = await createStore() + expect(store.getSettings().opencodeSessionCookie).toBe(legacyCookie) + expect(store.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(legacyOwnerLease) + }) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'saves non-secrets without exposing or destroying protected values: %s', + async (failureMode) => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + const originalCiphertext = readState() + expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), ORIGINAL) + + vi.advanceTimersByTime(60 * 60 * 1_000 + 1) + setFailure(failureMode) + await writeProtectedState(store, PENDING, 'during-failure') + + const primaryRaw = readFileSync(dataFile(), 'utf-8') + const backupRaw = readFileSync(`${dataFile()}.bak.0`, 'utf-8') + const persisted = readState() + expectPlaintextsAbsent(primaryRaw, PENDING) + expectPlaintextsAbsent(backupRaw, PENDING) + expect.soft(persisted.settings.httpProxyUrl).toBe(originalCiphertext.settings.httpProxyUrl) + expect + .soft(persisted.settings.opencodeSessionCookie) + .toBe(originalCiphertext.settings.opencodeSessionCookie) + expect + .soft(persisted.ui.browserKagiSessionLink) + .toBe(originalCiphertext.ui.browserKagiSessionLink) + expect + .soft(persisted.sshPtyConsumerRecoveries[0]?.ownerLease) + .toBe(originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease) + expect.soft(persisted.settings.httpProxyBypassRules).toBe('during-failure') + + const loadedDuringFailure = await createStore() + expect(loadedDuringFailure.getSettings().httpProxyBypassRules).toBe('during-failure') + await settleSave(loadedDuringFailure) + expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), PENDING) + + cipherState.availability = 'available' + cipherState.encryptionThrows = false + const recovered = await createStore() + expect(recovered.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(recovered.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(recovered.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(recovered.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + expect(recovered.getSettings().httpProxyBypassRules).toBe('during-failure') + + await writeProtectedState(recovered, PENDING, 'recovered') + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(PENDING.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(PENDING.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(PENDING.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(PENDING.ownerLease) + expect(restarted.getSettings().httpProxyBypassRules).toBe('recovered') + } + ) +}) diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts new file mode 100644 index 00000000000..20bbe78d5cf --- /dev/null +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const testState = { dir: '' } +const cipherState = { available: true } + +const renameGate = vi.hoisted(() => ({ + sourcePrefix: '', + release: null as Promise | null, + started: null as (() => void) | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (source: string, destination: string) => { + if (renameGate.release && source.startsWith(renameGate.sourcePrefix)) { + const release = renameGate.release + renameGate.release = null + renameGate.started?.() + await release + } + return actual.rename(source, destination) + } + } +}) + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) + } +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +describe('protected-secret async write retention', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-protected-secret-write-race-')) + cipherState.available = true + renameGate.sourcePrefix = join(testState.dir, 'orca-data.json') + renameGate.release = null + renameGate.started = null + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('does not retain ciphertext from a superseded async secret write', async () => { + const store = await createStore() + store.updateSettings({ opencodeSessionCookie: 'durable-cookie' }) + vi.advanceTimersByTime(1_000) + await store.waitForPendingWrite() + + const renameRelease = deferred() + const renameStarted = deferred() + renameGate.release = renameRelease.promise + renameGate.started = renameStarted.resolve + + store.updateSettings({ opencodeSessionCookie: 'intermediate-cookie' }) + vi.advanceTimersByTime(1_000) + await renameStarted.promise + + store.updateSettings({ opencodeSessionCookie: 'replacement-cookie' }) + cipherState.available = false + vi.advanceTimersByTime(1_000) + renameRelease.resolve() + await store.waitForPendingWrite() + + cipherState.available = true + const restarted = await createStore() + expect(restarted.getSettings().opencodeSessionCookie).toBe('durable-cookie') + }) +}) diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts new file mode 100644 index 00000000000..769ebec69ba --- /dev/null +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -0,0 +1,188 @@ +// STA-3442: httpProxyUrl is the only network setting stored via safeStorage. +// On macOS a keychain reset/denial makes decryptString throw at load, and the +// raw ciphertext then masqueraded as a configured proxy: applyElectronProxySettings +// silently fell back to DIRECT and the garbage re-persisted forever. These tests +// pin the recovery contract: undecryptable values stay sealed (never applied +// or destroyed), plaintext values survive as the upgrade path, and safeStorage +// failures cannot expose the proxy secret or kill unrelated saves. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' + +const testState = { dir: '' } + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +const cipherState = { + encryptionAvailable: true, + availabilityThrows: false, + decryptAlwaysThrows: false +} + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + session: { defaultSession: undefined }, + safeStorage: { + isEncryptionAvailable: () => { + if (cipherState.availabilityThrows) { + throw new Error('safeStorage cannot be used before the app is ready') + } + return cipherState.encryptionAvailable + }, + encryptString: (plaintext: string) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + if (cipherState.decryptAlwaysThrows) { + throw new Error('keychain access denied') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + } + } +})) + +vi.mock('./telemetry/client', () => ({ + track: vi.fn() +})) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function dataFile(): string { + return join(testState.dir, 'orca-data.json') +} + +const PROXY_URL = 'http://127.0.0.1:8080' +const BYPASS_RULES = '' + +describe('httpProxyUrl secret recovery (STA-3442)', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + cipherState.encryptionAvailable = true + cipherState.availabilityThrows = false + cipherState.decryptAlwaysThrows = false + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + async function seedConfiguredProxy() { + const store = await createStore() + store.updateSettings({ httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + return store + } + + it('persists the configured proxy across a restart and applies it as fixed_servers', async () => { + await seedConfiguredProxy() + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string; httpProxyBypassRules: string } + } + // On disk the URL is ciphertext (base64 of the mock's enc: payload), never plaintext. + expect(persisted.settings.httpProxyUrl).not.toBe(PROXY_URL) + expect(Buffer.from(persisted.settings.httpProxyUrl, 'base64').toString('utf-8')).toMatch( + /^enc:/ + ) + expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES) + + const reloaded = await createStore() + expect(reloaded.getSettings().httpProxyUrl).toBe(PROXY_URL) + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + + const { applyElectronProxySettings, resetProxyApplicationForTests } = + await import('./network/proxy-settings') + resetProxyApplicationForTests() + const setProxy = vi.fn(async () => {}) + const result = await applyElectronProxySettings(reloaded.getSettings(), { + proxySession: { resolveProxy: async () => 'DIRECT', setProxy }, + env: {} + }) + expect(result).toEqual({ + source: 'settings', + proxyRules: PROXY_URL, + proxyBypassRules: BYPASS_RULES + }) + expect(setProxy).toHaveBeenCalledWith({ + mode: 'fixed_servers', + proxyRules: PROXY_URL, + proxyBypassRules: BYPASS_RULES + }) + }) + + it('seals an undecryptable httpProxyUrl without destroying its ciphertext', async () => { + await seedConfiguredProxy() + const originalCiphertext = JSON.parse(readFileSync(dataFile(), 'utf-8')).settings.httpProxyUrl + + // Keychain reset/denial: every decrypt now fails. + cipherState.decryptAlwaysThrows = true + const reloaded = await createStore() + + expect(reloaded.getSettings().httpProxyUrl).toBe('') + // Bypass rules are not encrypted and must survive. + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + + // Unrelated durable changes must not erase ciphertext that can recover later. + reloaded.updateSettings({ httpProxyBypassRules: 'localhost' }) + vi.advanceTimersByTime(2000) + await reloaded.waitForPendingWrite() + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string } + } + expect(persisted.settings.httpProxyUrl).toBe(originalCiphertext) + }) + + it('keeps a plaintext httpProxyUrl on disk readable (pre-encryption/hand-edited upgrade path)', async () => { + mkdirSync(dirname(dataFile()), { recursive: true }) + writeFileSync( + dataFile(), + JSON.stringify({ settings: { httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES } }), + 'utf-8' + ) + + const store = await createStore() + expect(store.getSettings().httpProxyUrl).toBe(PROXY_URL) + expect(store.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + }) + + it('saves non-secret proxy settings without plaintext when availability throws', async () => { + cipherState.availabilityThrows = true + + const store = await createStore() + store.updateSettings({ httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + + expect(existsSync(dataFile())).toBe(true) + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string; httpProxyBypassRules: string } + } + expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES) + + const reloaded = await createStore() + expect(reloaded.getSettings().httpProxyUrl).toBe('') + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + }) +}) diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index 66c3b4ab0f6..c638ac12224 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -145,7 +145,7 @@ describe('persistence single-serialize save guard', () => { expect(reloaded.getUI().browserKagiSessionLink).toBe(KAGI_LINK) }) - it('handles empty secrets and unavailable encryption (payload stays plaintext, guard still skips)', async () => { + it('omits new secrets when encryption is unavailable and still skips an identical state', async () => { cipherState.encryptionAvailable = false const store = await createStore() store.updateSettings({ ...SECRETS, opencodeSessionCookie: '' }) @@ -156,7 +156,7 @@ describe('persistence single-serialize save guard', () => { settings: { opencodeSessionCookie: string; httpProxyUrl: string } } expect(persisted.settings.opencodeSessionCookie).toBe('') - expect(persisted.settings.httpProxyUrl).toBe(SECRETS.httpProxyUrl) + expect(persisted.settings.httpProxyUrl).toBe('') const inoBefore = statSync(dataFile()).ino store.updateSettings({ httpProxyUrl: SECRETS.httpProxyUrl }) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 81faa5f6297..b59ca6f5986 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4617,6 +4617,43 @@ describe('Store', () => { expect(store.getRepos()[0]!.upstream).toBeUndefined() }) + it('keeps the upstream host across reloads so it is never re-inferred from origin', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { + upstream: { owner: ' acme ', repo: ' widgets ', host: ' GHE.example:8443 ' } + }) + expect(updated!.upstream).toEqual({ + owner: 'acme', + repo: 'widgets', + host: 'GHE.example:8443' + }) + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.upstream).toEqual({ + owner: 'acme', + repo: 'widgets', + host: 'GHE.example:8443' + }) + }) + + it('leaves a hostless persisted upstream hostless rather than inventing one', async () => { + const store = await createStore() + store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } })) + + expect(store.getRepo('r1')!.upstream).toEqual({ owner: 'stablyai', repo: 'orca' }) + expect(store.getRepo('r1')!.upstream).not.toHaveProperty('host') + }) + + it('drops a blank upstream host instead of persisting an empty string', async () => { + const store = await createStore() + store.addRepo(makeRepo({ upstream: { owner: 'acme', repo: 'widgets', host: ' ' } })) + + expect(store.getRepo('r1')!.upstream).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + it('updateRepo returns null for nonexistent id', async () => { const store = await createStore() expect(store.updateRepo('nope', { displayName: 'x' })).toBeNull() @@ -9050,6 +9087,221 @@ describe('Store', () => { ).toBeUndefined() }) + it('reconciles only the incarnation of an unchanged durable PTY binding', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }) + + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }) + ).toBe(true) + + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe('inc-live') + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + }) + + it('rejects competing PTY and incarnation changes during reconciliation', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-current' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-current' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-current' } + }) + + for (const competing of [ + { ptyId: 'pty-replaced', expectedIncarnationId: 'inc-current' }, + { ptyId: 'pty-current', expectedIncarnationId: 'inc-replaced' } + ]) { + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: competing.ptyId, + incarnationId: 'inc-live', + expectedBinding: { + ptyId: competing.ptyId, + incarnationId: competing.expectedIncarnationId + } + }) + ).toBe(false) + } + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-current' + ) + }) + + it('reconciles only the requested execution-host partition', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf' as const, leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + } + store.setWorkspaceSession(structuredClone(session), 'local') + store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-remote-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }, + 'ssh:ssh-1' + ) + ).toBe(true) + + expect(store.getWorkspaceSession('local').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-stale' + ) + expect(store.getWorkspaceSession('ssh:ssh-1').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-remote-live' + ) + }) + + it.each([ + { label: 'local', hostId: undefined }, + { label: 'SSH', hostId: 'ssh:ssh-1' } + ])( + 'preserves a reconciled incarnation across a renderer snapshot ($label)', + async ({ hostId }) => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }, + hostId + ) + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }, + hostId + ) + ).toBe(true) + + const rendererSnapshot = structuredClone(store.getWorkspaceSession(hostId)) + delete rendererSnapshot.terminalPtyIncarnationsByPaneKey + delete rendererSnapshot.terminalTopologyRevisionByRepoId + store.setWorkspaceSession(rendererSnapshot, hostId) + + expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + } + ) + + it('rolls back incarnation reconciliation when the durability barrier fails', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }) + vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('disk full') + }) + + expect(() => + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }) + ).toThrow('disk full') + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-stale' + ) + }) + it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => { const store = await createStore() store.setWorkspaceSession({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2afccf7e0ae..08697613c31 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { app, safeStorage } from 'electron' +import { app } from 'electron' import { readFileSync, writeFileSync, @@ -38,6 +38,8 @@ import { } from '../shared/automation-schedules' import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' +import { normalizeProxyUrl } from '../shared/network-proxy' +import { normalizeKagiSessionLink } from '../shared/browser-url' import type { PersistedState, Project, @@ -279,36 +281,23 @@ import { import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' +import { + PROTECTED_SECRET_SLOT, + ProtectedSecretPersistence, + sshPtyOwnerLeaseSecretSlot, + type ProtectedSecretRetentionUpdate +} from './protected-secret-persistence' -function encrypt(plaintext: string): string { - if (!plaintext || !safeStorage.isEncryptionAvailable()) { - return plaintext - } - try { - return safeStorage.encryptString(plaintext).toString('base64') - } catch (err) { - console.error('[persistence] Encryption failed:', err) - return plaintext - } +function isLegacyOpenCodeSessionCookie(value: string): boolean { + const trimmed = value.trim() + return ( + trimmed.startsWith('Fe26.2**') || + trimmed.split(';').some((pair) => /^(?:auth|__Host-auth)=\S+$/i.test(pair.trim())) + ) } -function decrypt(ciphertext: string): string { - if (!ciphertext || !safeStorage.isEncryptionAvailable()) { - return ciphertext - } - try { - return safeStorage.decryptString(Buffer.from(ciphertext, 'base64')) - } catch { - // Why: decrypt failure usually means plaintext (pre-encryption) or a changed keychain; return raw so the cookie survives upgrade. - console.warn( - '[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.' - ) - return ciphertext - } -} - -function decryptOptionalSecret(value: string | null | undefined): string | null { - return value ? decrypt(value) : null +function isLegacySshPtyOwnerLease(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } function retireLegacyInstructionsForClearedTextActionRecipes( @@ -1399,10 +1388,18 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { if (!value || typeof value !== 'object') { return undefined } - const candidate = value as { owner?: unknown; repo?: unknown } + const candidate = value as { owner?: unknown; repo?: unknown; host?: unknown } const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : '' const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : '' - return owner && repo ? { owner, repo } : undefined + if (!owner || !repo) { + return undefined + } + // Why: an `upstream` remote may live on a different server than `origin`, so + // dropping the host forced consumers to re-infer it from origin and could bind + // a GHES parent to a same-named github.com repo. Absent host stays absent so + // records written before this survive unchanged. + const host = typeof candidate.host === 'string' ? candidate.host.trim() : '' + return host ? { owner, repo, host } : { owner, repo } } function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | null | undefined { @@ -2793,6 +2790,7 @@ export class Store { private pendingGithubCacheWrite: Promise | null = null private readonly staleGithubCacheTempCleanup: Promise private gitUsernameCache = new Map() + private readonly protectedSecrets = new ProtectedSecretPersistence() private loadNeedsSave = false private settingsChangeListeners = new Set< ( @@ -3055,13 +3053,43 @@ export class Store { // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { - parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie) + parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + parsed.settings.opencodeSessionCookie, + isLegacyOpenCodeSessionCookie + ) } if (parsed.settings?.httpProxyUrl) { - parsed.settings.httpProxyUrl = decrypt(parsed.settings.httpProxyUrl) + const decryptedProxy = this.protectedSecrets.decryptWithStatus( + PROTECTED_SECRET_SLOT.httpProxyUrl, + parsed.settings.httpProxyUrl, + (value) => normalizeProxyUrl(value).ok + ) + // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL + // value must not masquerade as a configured proxy (silent DIRECT fallback) or + // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. + if ( + decryptedProxy.status === 'unavailable' || + (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) + ) { + parsed.settings.httpProxyUrl = '' + } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { + parsed.settings.httpProxyUrl = decryptedProxy.plaintext + } else { + console.warn( + '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' + ) + parsed.settings.httpProxyUrl = '' + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + this.loadNeedsSave = true + } } if (parsed.ui?.browserKagiSessionLink) { - parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink) + parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + parsed.ui.browserKagiSessionLink, + (value) => normalizeKagiSessionLink(value) !== null + ) } parsed.sshPtyConsumerRecoveries = ( Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] @@ -3070,8 +3098,23 @@ export class Store { normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) ) .filter((record): record is SshPtyConsumerRecovery => record !== null) - .map((record) => ({ ...record, ownerLease: decrypt(record.ownerLease) })) - .map((record) => normalizeSshPtyConsumerRecovery(record)) + .map((record) => { + const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) + const decrypted = this.protectedSecrets.decryptWithStatus( + slot, + record.ownerLease, + isLegacySshPtyOwnerLease + ) + const normalized = + decrypted.status === 'unavailable' || + (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + this.protectedSecrets.removeRetainedBlob(slot) + } + return normalized + }) .filter((record): record is SshPtyConsumerRecovery => record !== null) // Merge with defaults in case new fields were added @@ -3872,8 +3915,12 @@ export class Store { return durable } - // Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). One full-state stringify serves both the on-disk payload and the no-op-write guard hash: each secret slot is serialized as a fresh unguessable sentinel, then sentinels are substituted to ciphertext for the payload and to plaintext for the hash. The hash is thus a pure function of plaintext state (skips a byte-identical rewrite) without a second stringify. - private buildStateToSave(): { payload: string; stateHash: string } { + // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. + private buildStateToSave(): { + payload: string + stateHash: string + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } { // Why sentinels (not a blob/key string match): the substitution must be // position-exact. A plain search for the ciphertext — or even for a // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an @@ -3883,54 +3930,79 @@ export class Store { // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot // random UUID can't occur anywhere else in the serialized state (the user // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; plaintext: string }[] = [] - const encryptToSentinel = (plaintext: string): string => { - const blob = encrypt(plaintext) - // Deterministic already (empty secret / safeStorage unavailable / encrypt - // failure): blob === plaintext, so no normalization — and no sentinel, - // which also avoids substituting an empty or plaintext-shaped slot. - if (blob === plaintext) { + const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + let protectedStorageDegraded = false + const encryptToSentinel = (slot: string, plaintext: string): string => { + const encrypted = this.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + protectedStorageDegraded ||= encrypted.degraded + const { blob, hashValue = plaintext } = encrypted + // Values already identical in payload and hash need no sentinel substitution. + if (blob === plaintext && hashValue === plaintext) { return blob } const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, blob, plaintext }) + secretSubs.push({ sentinel, blob, hashValue }) return sentinel } + const encryptOptionalToSentinel = ( + slot: string, + plaintext: string | null | undefined + ): string | null => { + const encrypted = encryptToSentinel(slot, plaintext ?? '') + return encrypted || null + } // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ ...record, - ownerLease: encryptToSentinel(record.ownerLease) + ownerLease: encryptToSentinel( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) })), settings: { ...this.state.settings, - opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie), - httpProxyUrl: encryptToSentinel(this.state.settings.httpProxyUrl ?? '') + opencodeSessionCookie: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.state.settings.opencodeSessionCookie + ), + httpProxyUrl: encryptToSentinel( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.state.settings.httpProxyUrl ?? '' + ) }, ui: { ...this.state.ui, - browserKagiSessionLink: this.state.ui.browserKagiSessionLink - ? encryptToSentinel(this.state.ui.browserKagiSessionLink) - : null + browserKagiSessionLink: encryptOptionalToSentinel( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.state.ui.browserKagiSessionLink + ) } } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, plaintext for the guard hash. Function-form replacement keeps - // `$` in blob/plaintext inert; both sides read the sentinel as JSON-escaped + // payload, a stable normalized value for the guard hash. Function-form + // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped // in `serialized`, so each replace is byte-for-byte position-exact. let payload = serialized let hashInput = serialized - for (const { sentinel, blob, plaintext } of secretSubs) { + for (const { sentinel, blob, hashValue } of secretSubs) { const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => blob) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(plaintext).slice(1, -1)) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) } - const stateHash = createHash('sha1').update(hashInput).digest('hex') - return { payload, stateHash } + const stateHash = createHash('sha1') + .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') + .update(hashInput) + .digest('hex') + return { payload, stateHash, protectedSecretUpdates } } // Why: async writes avoid blocking the main Electron thread on every debounced save. @@ -3939,7 +4011,7 @@ export class Store { return } const gen = this.writeGeneration - const { payload, stateHash } = this.buildStateToSave() + const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. if (stateHash === this.lastWrittenStateHash) { this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) @@ -3981,6 +4053,7 @@ export class Store { // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. if (renamed && this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash + this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) } else if (renamed) { this.lastWrittenStateHash = null } @@ -4007,7 +4080,7 @@ export class Store { if (this.writesFrozen) { return } - const { payload, stateHash } = this.buildStateToSave() + const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. if (!opts.force && stateHash === this.lastWrittenStateHash) { return @@ -4027,6 +4100,7 @@ export class Store { writeFileDurableSync(tmpFile, dataFile, payload) renamed = true this.lastWrittenStateHash = stateHash + this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) this.lastDurableWriteGeneration = Math.max( this.lastDurableWriteGeneration, this.writeGeneration @@ -5692,6 +5766,12 @@ export class Store { options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) + } + if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + } // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5916,6 +5996,9 @@ export class Store { } updateUI(updates: Partial): void { + if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) + } const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) const { activeView, ...durableUpdates } = sanitizedUpdates const activeViewChanged = this.activeViewPreference.set(activeView) @@ -6627,11 +6710,26 @@ export class Store { ptyId: string incarnationId?: string startupCwd?: string + expectedBinding?: { ptyId: string; incarnationId?: string } }, hostId?: string | null - ): void { + ): boolean { const resolvedHostId = this.resolveHostId(hostId) const session = this.getWorkspaceSession(resolvedHostId) + const paneKey = `${args.tabId}:${args.leafId}` + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[args.worktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === args.worktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId + ) { + return false + } + } if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, @@ -6639,15 +6737,17 @@ export class Store { } } const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const paneKey = `${args.tabId}:${args.leafId}` + const reconciledIncarnation = + args.expectedBinding !== undefined && + args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false - const advanceTopologyAfterMembershipChange = (): void => { + const advanceTopologyFence = (): void => { const repoId = getRepoIdFromWorktreeId(args.worktreeId) const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - if (!terminalMembershipChanged || currentRevision <= 0) { + if (!reconciledIncarnation && (!terminalMembershipChanged || currentRevision <= 0)) { return } - // Why: a real host-admitted spawn after a retirement must be distinguishable from a stale renderer replay. + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. session.terminalTopologyRevisionByRepoId = { ...session.terminalTopologyRevisionByRepoId, [repoId]: currentRevision + 1 @@ -6702,14 +6802,14 @@ export class Store { } if (!isTerminalLeafId(args.leafId)) { // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyAfterMembershipChange() + advanceTopologyFence() try { this.flushOrThrow() } catch (err) { restoreSession() throw err } - return + return true } const layout = session.terminalLayoutsByTabId?.[args.tabId] if (layout) { @@ -6750,13 +6850,14 @@ export class Store { } } } - advanceTopologyAfterMembershipChange() + advanceTopologyFence() try { this.flushOrThrow() } catch (err) { restoreSession() throw err } + return true } // ── SSH Targets ──────────────────────────────────────────────────── @@ -6803,6 +6904,7 @@ export class Store { } this.state.sshTargets = nextTargets this.state.sshPtyConsumerRecoveries = nextRecoveries + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(id)) this.scheduleSave() } @@ -6953,6 +7055,7 @@ export class Store { const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId) if (retainedRecoveries.length !== recoveries.length) { this.state.sshPtyConsumerRecoveries = retainedRecoveries + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(oldTargetId)) carrierChanged = true } let setupsChanged = false @@ -6995,6 +7098,12 @@ export class Store { const record = (this.state.sshPtyConsumerRecoveries ?? []).find( (candidate) => candidate.targetId === targetId ) + if ( + record && + this.protectedSecrets.isSealed(sshPtyOwnerLeaseSecretSlot(record.targetId), record.ownerLease) + ) { + return null + } return record ? structuredClone(record) : null } @@ -7018,6 +7127,7 @@ export class Store { return } this.state.sshPtyConsumerRecoveries = next + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) await this.flushSshPtyConsumerRecovery() } diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts new file mode 100644 index 00000000000..815edc5d477 --- /dev/null +++ b/src/main/protected-secret-persistence.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const cipherState = { available: true } + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`), + decryptString: (ciphertext: Buffer) => ciphertext.toString().slice('encrypted:'.length) + } +})) + +describe('ProtectedSecretPersistence', () => { + beforeEach(() => { + cipherState.available = true + }) + + it('evicts dynamic slots across repeated SSH recovery lifecycles', async () => { + const { ProtectedSecretPersistence, sshPtyOwnerLeaseSecretSlot } = + await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + const slots = Array.from({ length: 100 }, (_, index) => + sshPtyOwnerLeaseSecretSlot(`ssh-${index}`) + ) + + for (const slot of slots) { + expect(secrets.encrypt(slot, 'owner-lease').blob).not.toBe('owner-lease') + secrets.removeRetainedBlob(slot) + } + + cipherState.available = false + for (const slot of slots) { + expect(secrets.encrypt(slot, 'replacement-lease')).toEqual({ blob: '', degraded: true }) + } + }) + + it('keeps unavailable ciphertext sealed across recovery until replacement or clear', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + const slot = 'protected-slot' + const ciphertext = Buffer.from('encrypted:original').toString('base64') + + cipherState.available = false + expect(secrets.decryptWithStatus(slot, ciphertext)).toEqual({ + plaintext: '', + status: 'unavailable' + }) + expect(secrets.isSealed(slot, ciphertext)).toBe(true) + expect(secrets.encrypt(slot, '')).toEqual({ + blob: ciphertext, + degraded: true, + hashValue: ciphertext + }) + + cipherState.available = true + expect(secrets.encrypt(slot, '')).toEqual({ + blob: ciphertext, + degraded: false, + hashValue: ciphertext + }) + expect(secrets.encrypt(slot, 'replacement').blob).not.toBe(ciphertext) + + secrets.removeRetainedBlob(slot) + expect(secrets.encrypt(slot, '')).toEqual({ blob: '', degraded: false }) + }) +}) diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts new file mode 100644 index 00000000000..85ab3153043 --- /dev/null +++ b/src/main/protected-secret-persistence.ts @@ -0,0 +1,139 @@ +import { safeStorage } from 'electron' + +export const PROTECTED_SECRET_SLOT = { + opencodeSessionCookie: 'settings.opencodeSessionCookie', + httpProxyUrl: 'settings.httpProxyUrl', + browserKagiSessionLink: 'ui.browserKagiSessionLink' +} as const + +export function sshPtyOwnerLeaseSecretSlot(targetId: string): string { + return `sshPtyConsumerRecoveries.ownerLease:${targetId}` +} + +export type ProtectedSecretDecryption = { + plaintext: string + status: 'decrypted' | 'failed' | 'unavailable' +} + +export type ProtectedSecretRetentionUpdate = { + slot: string + blob: string | null +} + +export type LegacyPlaintextValidator = (value: string) => boolean + +type ProtectedSecretEncryption = { + blob: string + degraded: boolean + hashValue?: string + retentionUpdate?: ProtectedSecretRetentionUpdate +} + +// Preserve prior ciphertext or omit a new secret so unrelated state can still save safely. +export class ProtectedSecretPersistence { + private readonly retainedBlobs = new Map() + private readonly sealedSlots = new Set() + + removeRetainedBlob(slot: string): void { + this.retainedBlobs.delete(slot) + this.sealedSlots.delete(slot) + } + + isSealed(slot: string, value: string): boolean { + return this.sealedSlots.has(slot) && this.retainedBlobs.get(slot) === value + } + + commitRetentionUpdates(updates: readonly ProtectedSecretRetentionUpdate[]): void { + for (const update of updates) { + if (update.blob === null) { + this.removeRetainedBlob(update.slot) + } else { + this.retainedBlobs.set(update.slot, update.blob) + this.sealedSlots.delete(update.slot) + } + } + } + + encrypt(slot: string, plaintext: string): ProtectedSecretEncryption { + const retained = this.retainedBlobs.get(slot) ?? '' + if (!plaintext && !retained) { + return { blob: '', degraded: false } + } + if (!this.encryptionAvailable()) { + return { + blob: retained, + degraded: true, + ...(!plaintext && retained ? { hashValue: retained } : {}) + } + } + if (this.isSealed(slot, plaintext) || (!plaintext && this.sealedSlots.has(slot))) { + return { blob: retained, degraded: false, hashValue: retained } + } + if (!plaintext) { + return { + blob: '', + degraded: false, + retentionUpdate: { slot, blob: null } + } + } + try { + const blob = safeStorage.encryptString(plaintext).toString('base64') + return { + blob, + degraded: false, + retentionUpdate: { slot, blob } + } + } catch (err) { + console.error('[persistence] Encryption failed; retaining the prior protected value:', err) + return { blob: retained, degraded: true } + } + } + + decrypt(slot: string, ciphertext: string, isLegacyPlaintext?: LegacyPlaintextValidator): string { + return this.decryptWithStatus(slot, ciphertext, isLegacyPlaintext).plaintext + } + + decryptWithStatus( + slot: string, + ciphertext: string, + isLegacyPlaintext?: LegacyPlaintextValidator + ): ProtectedSecretDecryption { + if (!ciphertext) { + this.removeRetainedBlob(slot) + return { plaintext: '', status: 'decrypted' } + } + this.retainedBlobs.set(slot, ciphertext) + if (!this.encryptionAvailable()) { + this.sealedSlots.add(slot) + return { plaintext: '', status: 'unavailable' } + } + try { + const decrypted = { + plaintext: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')), + status: 'decrypted' as const + } + this.sealedSlots.delete(slot) + return decrypted + } catch { + if (isLegacyPlaintext?.(ciphertext)) { + this.sealedSlots.delete(slot) + console.warn('[persistence] safeStorage decryption failed; accepting legacy plaintext.') + return { plaintext: ciphertext, status: 'failed' } + } + this.sealedSlots.add(slot) + console.warn( + '[persistence] safeStorage decryption failed; retaining the protected value without exposing it.' + ) + return { plaintext: '', status: 'failed' } + } + } + + private encryptionAvailable(): boolean { + try { + return safeStorage.isEncryptionAvailable() + } catch (err) { + console.warn('[persistence] safeStorage availability check failed:', err) + return false + } + } +} diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts index 3d03ca6a269..a8924924f22 100644 --- a/src/main/providers/git-provider-status-options.ts +++ b/src/main/providers/git-provider-status-options.ts @@ -3,5 +3,7 @@ export type GitProviderStatusOptions = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string signal?: AbortSignal } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index d43158482c0..64dd9850930 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -38,6 +38,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { SessionNotFoundError } from '../daemon/daemon-errors' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' @@ -546,7 +547,7 @@ export class LocalPtyProvider implements IPtyProvider { } } if (args.attachOnly) { - throw new Error(`Session not found: ${args.sessionId ?? ''}`) + throw new SessionNotFoundError(args.sessionId ?? '') } const id = allocatePtyId(reattachId ?? undefined) const incarnationId = randomUUID() diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index b766b11497d..98ab3d9b69b 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -64,6 +64,10 @@ export type PtySpawnOptions = { isNewSession?: boolean /** Attach the named session atomically or fail without creating a process. */ attachOnly?: boolean + /** Exact persisted owner expected by an attach-only routing decision. */ + expectedIncarnationId?: PtyIncarnationId + /** True when runtime state makes the expected incarnation a hard attach fence. */ + expectedIncarnationIsAuthoritative?: boolean /** Why: allows the renderer to request a specific shell for a single new * terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without * changing the user's persistent default shell setting. Only consulted on diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 2ec2fbbfc6f..e4c306529ee 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -129,11 +129,16 @@ export class SshGitProvider implements IGitProvider { ? { bypassEffectiveUpstreamNegativeCache: true } : {} const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} + const branchLineTotalArgs = + options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } const request = { worktreePath, ...includeIgnoredArgs, ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs + ...lineStatsReuseArgs, + ...branchLineTotalArgs } return (await (options?.signal ? this.mux.request('git.status', request, { signal: options.signal }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 5cefa574248..68000f1b297 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -38,6 +38,7 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { sortDirEntries } from '../../shared/file-name-sort' import type { RuntimeFileListResult, RuntimeFileOpenResult, @@ -1292,7 +1293,9 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.readDir(target.path) + // Why: re-sort locally — the remote relay may be an older build with + // lexicographic ordering. + return sortDirEntries(await provider.readDir(target.path)) } const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) @@ -1307,12 +1310,7 @@ export class RuntimeFileCommands { } }) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } async watchFileExplorer( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0454246661c..90b7bb45f81 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1029,6 +1029,11 @@ class InMemoryOrchestrationMessages { private messages: MessageRow[] = [] + private runs = new Map< + string, + { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + >() + insertMessage(msg: { from: string to: string @@ -1084,6 +1089,30 @@ class InMemoryOrchestrationMessages { return this.activeCoordinatorRun } + setRun(run: { + id: string + coordinator_handle: string | null + coordinator_pane_key?: string | null + }): void { + this.runs.set(run.id, { coordinator_pane_key: null, ...run }) + } + + getRun( + id: string + ): + | { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + | undefined { + return this.runs.get(id) + } + + getCurrentRunForPane( + paneKey: string + ): + | { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + | undefined { + return [...this.runs.values()].find((run) => run.coordinator_pane_key === paneKey) + } + markAsDelivered(ids: string[]): void { const deliveredIds = new Set(ids) for (const message of this.messages) { @@ -8446,6 +8475,168 @@ describe('OrcaRuntimeService', () => { }) }) + // Why: this pins the mechanism the refusals below exist for. cursor-agent emits only the + // bare native title, and the tracker drops it on sight — so a pane can never hold it + // because Cursor said so *now*. The one route into main's records is the stale-working + // clear stripping the spinner off Orca's synthesized title after 3s of quiet output, and + // that fires whether Cursor parked idle or exited and the shell took the pane back. That + // is exactly why the title cannot tell a live pane from a dead one. + it('only records the bare Cursor native title via the stale-working clear', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + // Live from cursor-agent: dropped, never recorded. + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 100) + expect((await runtime.listTerminals()).terminals[0].title).not.toBe('Cursor Agent') + + // Orca's synthesized spinner, then quiet output: the clear strips it to the bare title. + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 101) + runtime.onPtyData(ptyId, 'agent finished; shell prompt returns\r\n', 102) + await vi.advanceTimersByTimeAsync(3_000) + + expect((await runtime.listTerminals()).terminals[0].title).toBe('Cursor Agent') + } finally { + vi.useRealTimers() + } + }) + + // Why: this pane reads no foreground and the next reads a live shell, yet both hold the + // same bare title the stale-working clear left behind. Neither read makes that title + // liveness, so both must refuse. + it('refuses a bare Cursor title while the foreground read is unavailable', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100) + runtime.onPtyData(ptyId, 'streaming output with no title\r\n', 101) + await vi.advanceTimersByTimeAsync(3_000) + + const terminal = (await runtime.listTerminals()).terminals[0] + expect(terminal.title).toBe('Cursor Agent') + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + isRunningAgent: false, + status: null + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not treat a bare Cursor title as an agent once the shell owns the foreground', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + let foreground: string | null = null + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => foreground, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100) + runtime.onPtyData(ptyId, 'agent exited; back at the shell\r\n', 101) + await vi.advanceTimersByTimeAsync(3_000) + + // cursor-agent is gone and the user's shell owns the pane, but the title still reads + // "Cursor Agent". A guarded send here would auto-submit Enter into that shell. + foreground = 'zsh' + const terminal = (await runtime.listTerminals()).terminals[0] + expect(terminal.title).toBe('Cursor Agent') + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + isRunningAgent: false, + status: null + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the refusals here must stay scoped to missing evidence. A working foreground read + // is what unlocks a live Cursor pane — and is the layer to fix if one is ever refused. + it('accepts a bare Cursor title when the foreground read confirms cursor-agent', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + let foreground: string | null = null + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => foreground, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100) + runtime.onPtyData(ptyId, 'streaming output with no title\r\n', 101) + await vi.advanceTimersByTimeAsync(3_000) + + foreground = 'cursor-agent' + const terminal = (await runtime.listTerminals()).terminals[0] + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + }) + + // Why: pins the type-narrowing branch, not a reachable state — no caller detaches the + // controller. It is the runtime-owned pty path, which the window-graph leaf tests below + // never reach, so nothing else would notice it being widened. + it('refuses a bare Cursor title on a runtime pty with no controller attached', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent', + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100) + runtime.onPtyData(ptyId, 'streaming output with no title\r\n', 101) + await vi.advanceTimersByTimeAsync(3_000) + + const terminal = (await runtime.listTerminals()).terminals[0] + runtime.setPtyController(null) + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + }) + it('clears a stale working title after 3s of title-less output', async () => { vi.useFakeTimers() try { @@ -13948,6 +14139,212 @@ describe('OrcaRuntimeService', () => { ) }) + // Why (#10333): `orca serve` publishes a ready graph under + // HEADLESS_RUNTIME_WINDOW_ID with no BrowserWindow behind it, so every + // focus-requested create used to fall through to getAuthoritativeWindow(). + const wireHeadlessServeRuntime = (): OrcaRuntimeService => { + const runtime = new OrcaRuntimeService(store) + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + return runtime + } + + it('spawns focus-requested CLI terminal creates in background on headless serve', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-headless' }) + const runtime = wireHeadlessServeRuntime() + const revealTerminalSession = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + // `orca terminal create --worktree --command "echo test" --focus` + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'echo test', + focus: true + }) + ).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + handle: expect.stringMatching(/^term_/) + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'echo test', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true + }) + ) + // Why: degrading the create must not silently drop the focus request — the + // host still asks whatever surface exists to activate the new pane. + expect(revealTerminalSession).toHaveBeenCalledWith( + TEST_WORKTREE_ID, + expect.objectContaining({ activate: true, presentation: 'focused' }) + ) + }) + + it('spawns presentation:focused terminal creates in background on headless serve', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-presentation-headless' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + // Paired desktop `+` button: clients send presentation:'focused', not focus. + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + presentation: 'focused' + }) + ).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + surface: 'background', + handle: expect.stringMatching(/^term_/) + }) + expect(spawn).toHaveBeenCalled() + }) + + it('spawns focused agent-session creates in background on headless serve', async () => { + // Why: RPC only downgrades `focused` for clients that report a clientKind + // (#10193), so loopback/CLI callers still reach the runtime asking for focus. + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-headless' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createAgentSession({ + clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`, + worktree: `path:${TEST_WORKTREE_PATH}`, + agent: 'codex', + prompt: 'hello', + presentation: 'focused' + }) + ).resolves.toMatchObject({ + disposition: 'created', + terminal: expect.objectContaining({ + worktreeId: TEST_WORKTREE_ID, + handle: expect.stringMatching(/^term_/) + }) + }) + expect(spawn).toHaveBeenCalled() + }) + + it('activates the paired mobile session tab for a degraded focused headless create', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-publish' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'echo test', + focus: true + }) + + // Why: with no renderer notifier on a serve host, the session-tab publish is + // the only channel a paired client learns about the terminal on — a degraded + // focused create must still land there selected, or focus is silently lost. + const tabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const published = tabs.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === created.tabId + ) + expect(published).toBeDefined() + expect(published?.isActive).toBe(true) + expect(tabs.activeTabId).toBe(published?.id) + }) + + it('keeps focus-requested terminal creates on the renderer when a window exists', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-should-not-spawn' }) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + title: 'Focused Terminal', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-focused', + paneTitle: null + } + ] + }) + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-focused', title: 'Focused Terminal' } + ) + }) + webContents.send = send + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'echo test', + focus: true + }) + ).resolves.toMatchObject({ + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + surface: 'visible' + }) + expect(send).toHaveBeenCalledWith( + 'terminal:requestTabCreate', + expect.objectContaining({ activate: true, presentation: 'focused' }) + ) + expect(spawn).not.toHaveBeenCalled() + }) + it('accepts renderer-backed terminal create replies only from the target renderer', async () => { const webContents = { send: vi.fn() } const send = vi.fn((_channel: string, payload: { requestId: string }) => { @@ -18100,23 +18497,24 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello')) - // Why: the split Enter write lands after the 500ms delay; advance past it before asserting on delivered_at. + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(500) expect(write).toHaveBeenCalledWith('pty-1', '\r') - // Why: design doc §3.2 splits delivered vs. read — push-on-idle stamps delivered_at but must not flip read; only the agent's check consumes, so rows stay unread. const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() } }) - it('injects pending orchestration messages into the active coordinator without auto-submitting', async () => { + it('submits the mail pointer in an active coordinator pane', async () => { vi.useFakeTimers() try { const runtime = new OrcaRuntimeService(store) @@ -18144,18 +18542,18 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: hello coordinator') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(500) const submitWrites = write.mock.calls.filter( ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' ) - expect(submitWrites).toHaveLength(0) + expect(submitWrites).toHaveLength(1) const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -18183,18 +18581,20 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello cursor')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(500) const submitWrites = write.mock.calls.filter( ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' ) expect(submitWrites).toHaveLength(0) - // Why: Cursor Agent treats injected PTY text as editable prompt input, so the user submits manually but the banner must not replay on the next idle transition. const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -18223,7 +18623,10 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) await vi.advanceTimersByTimeAsync(500) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello claude')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) expect(write).toHaveBeenCalledWith('pty-1', '\r') db.close() } finally { @@ -18254,16 +18657,16 @@ describe('OrcaRuntimeService', () => { await vi.advanceTimersByTimeAsync(500) const firstInjections = write.mock.calls.filter( - (c) => typeof c[1] === 'string' && c[1].includes('Subject: hello') + (c) => typeof c[1] === 'string' && c[1].includes('orca orchestration check') ).length expect(firstInjections).toBe(1) - // Second idle transition: the row is unread but already delivered, so push-on-idle must skip it to avoid the replay bug. + // The row remains pending, so the in-memory sequence watermark prevents replay. runtime.deliverPendingMessagesForHandle(terminal.handle) await vi.advanceTimersByTimeAsync(500) const totalInjections = write.mock.calls.filter( - (c) => typeof c[1] === 'string' && c[1].includes('Subject: hello') + (c) => typeof c[1] === 'string' && c[1].includes('orca orchestration check') ).length expect(totalInjections).toBe(1) db.close() @@ -21672,6 +22075,105 @@ describe('OrcaRuntimeService', () => { await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) }) + it('does not treat a bare Cursor Agent native title as a running agent session', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + // Why: the native title is identity, not liveness — Cursor never decorates it, so it + // reads the same whether cursor-agent is parked or long gone. Sends auto-submit Enter, + // so identity alone must not unlock one. + syncSinglePty(runtime, 'pty-1', { tabTitle: 'bash', paneTitle: 'Cursor Agent' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + isRunningAgent: false, + status: null + }) + }) + + // Why: a leaf with no PTY is the same no-evidence case as an unreadable foreground — + // nothing was even asked, so the bare title is all that is left. The corroborating + // foreground here is deliberately unreachable: no ptyId means no read. + it('does not treat a bare Cursor title as an agent on a leaf with no pty', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent' + }) + syncSinglePty(runtime, null, { tabTitle: 'bash', paneTitle: 'Cursor Agent' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + }) + + // Why: a renderer can push the bare title straight onto the pane, skipping the stale + // clear the other tests drive. Arriving that way it lands on top of a `working` status + // the spinner left behind, so the pane looks doubly like an agent — and is still just a + // shell. The tab is left untitled so the bare title is the only evidence in play: the + // refusal is decided at the foreground, and the stale-status gate is held shut behind it. + it('does not let a renderer-pushed bare Cursor title revive stale agent status', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh' + }) + syncSinglePty(runtime, 'pty-1', { tabTitle: '' }) + runtime.onPtyData('pty-1', '\x1b]0;⠋ Cursor Agent\x07', 100) + syncSinglePty(runtime, 'pty-1', { tabTitle: '', paneTitle: 'Cursor Agent' }) + const [terminal] = (await runtime.listTerminals()).terminals + + expect(terminal.title).toBe('Cursor Agent') + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + }) + + // Why: pins the outer catch, not a reachable state — the production controller + // (src/main/ipc/pty.ts) already normalizes provider failures, a dropped SSH channel + // included, to null before this sees them. Nothing else here makes the read throw. + it('does not treat a bare Cursor title as an agent when the foreground read throws', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => { + throw new Error('ssh channel closed') + } + }) + syncSinglePty(runtime, 'pty-1', { tabTitle: '', paneTitle: 'Cursor Agent' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + }) + + // Why: cursor-agent is a node program, so `node` in the foreground plus a Cursor title + // looks like corroboration. It is not — the wrapper retry has to resolve a real agent + // name, and timing out means it never did. + it('does not treat a bare Cursor title as an agent behind a wrapper foreground', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'node' + }) + syncSinglePty(runtime, 'pty-1', { tabTitle: '', paneTitle: 'Cursor Agent' }) + const [terminal] = (await runtime.listTerminals()).terminals + + const running = runtime.isTerminalRunningAgent(terminal.handle) + await vi.advanceTimersByTimeAsync(7_000) + await expect(running).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + }) + it('does not recognize runtime-created Claude agents management screens as agents', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -32787,6 +33289,807 @@ describe('OrcaRuntimeService', () => { await task }) + it('delivers pending mail via notifyMessageArrived when the recipient is already idle', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'after wait' + }) + + // Why: notifyMessageArrived is the send-path hook; it must push-on-idle + // without requiring another agent-status transition (#12536). + runtime.notifyMessageArrived(terminal.handle, 'status') + + // The push is deferred one microtask so it lands behind any resolved check. + await Promise.resolve() + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) + expect(write).not.toHaveBeenCalledWith('pty-1', expect.stringContaining('after wait')) + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('points a Run mailbox at its live-idle coordinator without replaying pending rows', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + db.setRun({ + id: 'run_mailbox', + coordinator_handle: terminal.handle, + coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}` + }) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_mailbox', + subject: 'one P3 finding', + body: 'private worker report', + type: 'worker_done' + }) + + runtime.notifyMessageArrived('run:run_mailbox', 'worker_done') + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + expect(write).toHaveBeenCalledWith( + 'pty-1', + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + expect(write).not.toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('private worker report') + ) + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(message.delivered_at).toBeNull() + + runtime.notifyMessageArrived('run:run_mailbox', 'worker_done') + await Promise.resolve() + await vi.advanceTimersByTimeAsync(500) + expect( + write.mock.calls.filter( + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') + ) + ).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('points already-idle Run mail after Codex replaces its completion title', async () => { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => 'codex' + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + db.setRun({ + id: 'run_codex_native_title', + coordinator_handle: terminal.handle, + coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}` + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + runtime.onPtyData('pty-1', '\x1b]0;fix-12953-orchestration-mail-pointer\x07', 101) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + db.insertMessage({ + from: 'term_worker', + to: 'run:run_codex_native_title', + subject: 'real-agent smoke complete', + body: 'The package name is orca.', + type: 'worker_done' + }) + + runtime.notifyMessageArrived('run:run_codex_native_title', 'worker_done') + await Promise.resolve() + + await vi.waitFor(() => { + expect(write).toHaveBeenCalledWith( + 'pty-1', + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + }) + db.close() + }) + + it('does not inject pending mail on notify when the recipient is still working', async () => { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'while working' + }) + write.mockClear() + + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + // Why: busy must leave the row undelivered so a later idle can push it; + // a stamp-without-write would suppress later delivery (#12584 CodeRabbit). + expect(message.delivered_at).toBeNull() + db.close() + }) + + it('delivers on a first live idle frame that follows a seeded idle with no transition', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.seedTerminalRestoreTail('pty-1', { lastTitle: 'Codex done' }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'restored idle' + }) + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + write.mockClear() + + // Why no working frame: a resumed agent sitting at its prompt emits an + // already-idle title first. The seed left lastAgentStatus 'idle', so there + // is no transition — only the liveness edge can release the row (#12536). + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 100) + + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not push on a cold-restore seeded idle status with no live observation', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + // Why: the persisted title is historical — the agent may have gone busy + // across the relaunch, so a seeded 'idle' must not authorize a PTY write. + runtime.seedTerminalRestoreTail('pty-1', { lastTitle: 'Codex done' }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'seeded idle' + }) + write.mockClear() + + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + + // The first live idle frame authorizes it and the row still delivers. + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('lets a resolved check consume its rows before a later same-tick notify pushes', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + // Why: resolveMessageWaiter removes the waiter synchronously, but the check + // handler marks its rows read a microtask later. Two sends resuming off one + // shared in-flight promise put a no-waiter notify inside that window, so the + // push must not inject rows the resolved check is about to return. + const consumed = runtime + .waitForMessage(terminal.handle, { timeoutMs: 5_000 }) + .then(() => db.getUnreadMessages(terminal.handle).map((row) => (row.read = 1))) + const first = db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'pulled' }) + runtime.notifyMessageArrived(terminal.handle, 'status') + const second = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'also pulled' + }) + runtime.notifyMessageArrived(terminal.handle, 'status') + + await consumed + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + expect(first.delivered_at).toBeNull() + expect(second.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('leaves rows a live filtered waiter reserved out of the pushed batch', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + // Why: the push reads every pending row, not just the one that woke it. A + // `status` notify is unclaimed and pushes, but the worker_done row landing + // in the same drain belongs to this waiter's check — injecting it too would + // deliver that completion twice (pane + check return). + const waitPromise = runtime.waitForMessage(terminal.handle, { + typeFilter: ['worker_done'], + timeoutMs: 5_000 + }) + const status = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'unclaimed status', + type: 'status' + }) + runtime.notifyMessageArrived(terminal.handle, 'status') + const done = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'reserved completion', + type: 'worker_done' + }) + runtime.notifyMessageArrived(terminal.handle, 'worker_done') + + await expect(waitPromise).resolves.toBe('notified') + await vi.advanceTimersByTimeAsync(600) + const payloads = write.mock.calls + .map(([, data]) => data) + .filter((data): data is string => typeof data === 'string') + expect(payloads).toContain( + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + expect(payloads.some((data) => data.includes('reserved completion'))).toBe(false) + expect(status.delivered_at).toBeNull() + expect(done.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('skips rows claimed by a waiter that registered after the notify', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'claimed late', + type: 'status' + }) + // Why: the notify snapshot is empty — no waiter existed yet. A check that + // blocks before the deferred push runs still owns this row, so only the + // push-time read of live waiters can keep it out of the pane. + runtime.notifyMessageArrived(terminal.handle, 'status') + const waitPromise = runtime.waitForMessage(terminal.handle, { + typeFilter: ['status'], + timeoutMs: 5_000 + }) + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + expect(message.delivered_at).toBeNull() + + await vi.advanceTimersByTimeAsync(5_000) + await expect(waitPromise).resolves.toBe('timed_out') + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not carry pty-record live authority into a rebuilt leaf after a same-id respawn', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + // Why a UUID leaf id: the retirement fence's pty-candidate clause compares + // parsePaneKey(pty.paneKey).leafId to the republished leafId, and a non-UUID + // id falls back to `tabId:paneRuntimeId`, so it is always fenced after exit. + const leafId = '11111111-1111-1111-8111-111111111111' + const syncUuidLeaf = (): void => { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: null + } + ] + }) + } + syncUuidLeaf() + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + runtime.onPtyExit('pty-1', 0) + runtime.onPtySpawned('pty-1', undefined, { awaitsRegistration: false }) + // Drop the leaf, then republish it: the rebuilt record's tailSource is the + // PTY record rather than the previous leaf, which is what pins the clear + // onPtyExit applies at the pty level. + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + syncUuidLeaf() + + const leaves = ( + runtime as unknown as { + leaves: Map< + string, + { lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean } + > + } + ).leaves + expect(leaves.size).toBeGreaterThan(0) + const rebuilt = [...leaves.values()][0] + expect(rebuilt.lastAgentStatus).toBe('idle') + expect(rebuilt.lastAgentStatusObservedLive).toBe(false) + + setInMemoryOrchestrationMessages(runtime, db) + const [republished] = (await runtime.listTerminals()).terminals + const message = db.insertMessage({ + from: 'sender', + to: republished.handle, + subject: 'rebuilt leaf' + }) + runtime.notifyMessageArrived(republished.handle, 'status') + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('keeps live idle authority across a renderer graph republish', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + // Why: syncWindowGraph rebuilds every leaf record on any pane/tab change. + // An idle agent emits no new title, so dropping the live-status carry here + // would strand mail until the next OSC frame — the #12536 symptom. + syncSinglePty(runtime) + + const [republished] = (await runtime.listTerminals()).terminals + const message = db.insertMessage({ + from: 'sender', + to: republished.handle, + subject: 'after republish' + }) + + runtime.notifyMessageArrived(republished.handle, 'status') + await Promise.resolve() + + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not reuse the dead process live idle authority after a same-id respawn', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + // Why: a cold restore respawns under the same session id and makes the + // leaf writable again before any new title. The dead process's live idle + // must not authorize typing into its replacement mid-turn. + runtime.onPtyExit('pty-1', 0) + runtime.onPtySpawned('pty-1', undefined, { awaitsRegistration: false }) + setInMemoryOrchestrationMessages(runtime, db) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'after same id respawn' + }) + + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + + // The replacement's first live idle frame re-authorizes delivery — with no + // working frame, since exit keeps lastAgentStatus 'idle' for `ps` and the + // replacement can come up straight at an idle prompt (no transition). + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 200) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('pushes to an idle pane when the only live waiter filters out the message type', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + + // Why: a `check --wait --types worker_done` waiter never returns a status + // row — check re-reads under the same filter — so it is not the consumer + // and treating it as one would strand the message (#12536). + const waitPromise = runtime.waitForMessage(terminal.handle, { + typeFilter: ['worker_done'], + timeoutMs: 5_000 + }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'unfiltered status', + type: 'status' + }) + write.mockClear() + + runtime.notifyMessageArrived(terminal.handle, 'status') + + await Promise.resolve() + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + + // The filtered waiter stays blocked; the push did not consume its wake. + await vi.advanceTimersByTimeAsync(5_000) + await expect(waitPromise).resolves.toBe('timed_out') + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('resolves a registered waiter without PTY-injecting when the leaf is already idle', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'for check wait' + }) + write.mockClear() + + // Why: blocked orchestration.check --wait is an explicit pull; push must + // not stamp delivered_at or type into the pane (double delivery, #12584). + const waitPromise = runtime.waitForMessage(terminal.handle, { timeoutMs: 5_000 }) + runtime.notifyMessageArrived(terminal.handle, 'status') + + await expect(waitPromise).resolves.toBe('notified') + expect(write).not.toHaveBeenCalled() + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not re-inject the same message when notify fires again during Enter delay', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'once only' }) + + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + + const pointerWrites = write.mock.calls.filter( + ([, payload]) => typeof payload === 'string' && payload.includes('orca orchestration check') + ) + expect(pointerWrites).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(500) + const enterWrites = write.mock.calls.filter(([, payload]) => payload === '\r') + expect(enterWrites).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('delivers a second message parked during Enter delay once the flight settles', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + const first = db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'first' }) + runtime.notifyMessageArrived(terminal.handle, 'status') + // Why the flush: the deferred push must actually arm its flight before the + // second message arrives, or this exercises a plain batch instead. + await Promise.resolve() + + // Why: mid-flight notify parks the leaf; flight settle re-runs delivery + // so the second row is not lost and is not double-injected with the first. + const second = db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'second' }) + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + expect( + write.mock.calls.filter( + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') + ) + ).toHaveLength(1) + expect(second.delivered_at).toBeNull() + + // Why: release must not require another agent-status OSC — only the + // delayed-Enter flight timer. Advancing 3s with no status output covers + // timer-only settle (CodeRabbit settling-timeout gap, #12584). + await vi.advanceTimersByTimeAsync(3_000) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(first.delivered_at).toBeNull() + expect( + write.mock.calls.filter( + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') + ) + ).toHaveLength(2) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 2 orchestration messages') + ) + expect(second.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { const runtime = new OrcaRuntimeService(store) const db = new InMemoryOrchestrationMessages() @@ -32807,7 +34110,10 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) db.close() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5e23498f370..72bd2b0c12f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10,6 +10,7 @@ import { normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' +import { sortDirEntries } from '../../shared/file-name-sort' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' import { parseFileUriPathParts } from '../daemon/osc7-file-uri' @@ -133,7 +134,7 @@ import type { OrchestrationWorkerServer } from './orchestration/environment-transport' import { syncFederatedDispatch } from './orchestration/federation-sync' -import { formatMessagesForInjection } from './orchestration/formatter' +import { formatMessagePointer } from './orchestration/formatter' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' import type { Automation, @@ -1213,6 +1214,10 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { // full tail. See computeTerminalTailWaitState. tailWaitState?: TerminalTailWaitState lastAgentStatus: AgentStatus | null + // Why: seeded status is a historical title replayed on restore, so it cannot + // authorize a PTY write. Only a live OSC observation sets this true; push + // delivery reads it so a cold-restored `idle` never types into a working agent. + lastAgentStatusObservedLive: boolean // Why: the most recent OSC title observed on this leaf's PTY data. Used by // worktree.ps so daemon-hosted terminals (no renderer pushing pane titles) // still recompute working/idle from the live title each call instead of @@ -1252,6 +1257,8 @@ type RuntimePtyWorktreeRecord = { disconnectedAt: number | null lastExitCode: number | null lastAgentStatus: AgentStatus | null + /** False until a live OSC frame sets the status; restore seeds never set it. */ + lastAgentStatusObservedLive: boolean lastOscTitle: string | null lastOscTitleAt: number | null // Why a second stamp: `lastOscTitleAt` is a title-observation sequence number, @@ -1292,6 +1299,7 @@ type TerminalCreateOptions = { // CLI is `cursor-agent`). Callers that know the agent name it here instead of // guessing a command, and the runtime builds the configured launch. startupAgent?: TuiAgent + launchPreferences?: AgentLaunchPreferences terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] @@ -1974,6 +1982,24 @@ type MessageWaiter = { export type MessageWaitResult = 'notified' | 'timed_out' | 'cancelled' | 'waiter_exists' +// Why: an unfiltered waiter claims every type. A row a live waiter will return +// from orchestration.check must not also be pushed into the pane — check reads +// by `read`, push stamps `delivered_at`, so neither hides the row from the other. +function messageTypeHasLiveWaiter( + waiters: Set | undefined, + messageType: string +): boolean { + if (!waiters) { + return false + } + for (const waiter of waiters) { + if (!waiter.typeFilter || waiter.typeFilter.includes(messageType)) { + return true + } + } + return false +} + function omitUndefinedProperties>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== undefined) @@ -2852,6 +2878,8 @@ export class OrcaRuntimeService { private handles = new Map() private handleByLeafKey = new Map() private handleByPtyId = new Map() + // Why: pointer state is process-local; one harmless replay after restart avoids a wire or schema change. + private readonly lastPointedMessageSequenceByHandle = new Map() private syntheticTerminalHandles = new Set() private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] @@ -3083,6 +3111,7 @@ export class OrcaRuntimeService { // concurrent first-subscribes and keeps later subscribes no-ops; it is // cleared per lifecycle generation (exit/respawn) and on failed attempts. private subscriberDrivenProviderAttachesByPtyId = new Map>() + private subscriberDrivenProviderAttachInventoryWaiters = new Set() // Why: a spawn through this app already attaches its provider stream, so // subscriber-driven attach and the never-attached read fallback must target // only inventory-discovered sessions no local spawn published this @@ -5497,6 +5526,7 @@ export class OrcaRuntimeService { preview: tailSource?.preview ?? '', waitBlockedAt: tailSource?.waitBlockedAt ?? null, lastAgentStatus: tailSource?.lastAgentStatus ?? null, + lastAgentStatusObservedLive: tailSource?.lastAgentStatusObservedLive ?? false, lastOscTitle: tailSource?.lastOscTitle ?? null, lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, paneTitleUpdatedAt: @@ -10227,6 +10257,7 @@ export class OrcaRuntimeService { pty.lastOscTitleAt = observedAt pty.lastOscTitleEpochMs = Date.now() pty.lastAgentStatus = agentStatus + pty.lastAgentStatusObservedLive = true this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus if (agentStatus === 'idle' && prevStatus !== 'idle') { @@ -10264,6 +10295,7 @@ export class OrcaRuntimeService { leaf.lastOscTitle = normalizedTitle leaf.lastOscTitleAt = this.nextTitleObservationSequence() const prevStatus = leaf.lastAgentStatus + const prevObservedLive = leaf.lastAgentStatusObservedLive // Why: when a new OSC title doesn't classify as an agent state (e.g. // bare shell title after the agent exits), clear lastAgentStatus so // it is no longer sticky. Tui-idle waiters that needed the previous @@ -10272,6 +10304,7 @@ export class OrcaRuntimeService { // exits would observe the cleared value, and they correctly fall // back to title-based detection / polling. leaf.lastAgentStatus = agentStatus + leaf.lastAgentStatusObservedLive = true // Why: resolve tui-idle on any transition TO idle (not just working→idle). // Claude Code may skip "working" entirely on fast tasks, going null→idle, // and the coordinator's tui-idle waiter would hang forever waiting for a @@ -10280,7 +10313,15 @@ export class OrcaRuntimeService { // which isn't a task-completion signal. if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolveTuiIdleWaiters(leaf) - this.deliverPendingMessages(leaf) + } + // Why the second condition: push delivery is gated on LIVE idle, so its + // authorizing edge is liveness as well as status. A restore seed or a + // status kept across a same-id respawn leaves a stale 'idle' behind, and + // an agent whose first live title is already idle (claude --resume at its + // prompt) then shows no transition — the row would strand, which is + // exactly #12536. Waiter semantics stay transition-only above. + if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { + this.deliverPendingMessagesForLeaf(leaf) } } return ptyRecordChanged @@ -10309,6 +10350,9 @@ export class OrcaRuntimeService { pty.lastOscTitleAt = null pty.lastOscTitleEpochMs = null pty.lastAgentStatus = null + // Why: the prior process's live frames say nothing about the replacement, + // so the seed a same-id restore applies must not inherit its authority. + pty.lastAgentStatusObservedLive = false pty.managementTitle = null pty.managementTitleAt = null } @@ -10316,6 +10360,7 @@ export class OrcaRuntimeService { leaf.lastOscTitle = null leaf.lastOscTitleAt = null leaf.lastAgentStatus = null + leaf.lastAgentStatusObservedLive = false } this.clearAgentRowSnapshotsForPty(ptyId) } @@ -10553,6 +10598,7 @@ export class OrcaRuntimeService { this.legacyWorkerRecoveredPtys.delete(ptyId) // Why: a respawn under the same session id needs its own subscriber-driven attach. this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) this.spawnPublishedPtys.delete(ptyId) // Why: a provider response belongs to the process generation that issued // it; a respawn must neither reuse its frame nor join its in-flight call. @@ -10762,10 +10808,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. @@ -10802,6 +10845,31 @@ export class OrcaRuntimeService { }) } + private reconcileSubscriberDrivenProviderAttach(ptyId: string): void { + if (!this.hasRemoteTerminalViewSubscriber(ptyId)) { + return + } + const pending = this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) + if (!pending) { + this.ensureSubscriberDrivenProviderAttach(ptyId) + return + } + if (this.subscriberDrivenProviderAttachInventoryWaiters.has(ptyId)) { + return + } + this.subscriberDrivenProviderAttachInventoryWaiters.add(ptyId) + void pending.then((attached) => { + this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) + if (attached || !this.hasRemoteTerminalViewSubscriber(ptyId)) { + return + } + if (this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) === pending) { + this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + } + this.ensureSubscriberDrivenProviderAttach(ptyId) + }) + } + /** Mark a raw-output viewer without transferring terminal query authority. */ registerRawTerminalViewSubscriber(ptyId: string): () => void { this.rawTerminalViewSubscriberCounts.set( @@ -11197,10 +11265,11 @@ export class OrcaRuntimeService { // Why: seed-derived agent status reflects historical state. Orchestration // waiters (resolveTuiIdleWaiters, deliverPendingMessages) must only react - // to LIVE transitions, so this helper writes leaf.lastAgentStatus only and - // never resolves waiters. detectAgentStatusFromTitle wrap mirrors the live - // path so seeded and live values are the same union member, keeping - // downstream `=== 'idle'` checks correct. + // to LIVE transitions, so this helper writes leaf.lastAgentStatus only, + // leaves lastAgentStatusObservedLive untouched, and never resolves waiters. + // detectAgentStatusFromTitle wrap mirrors the live path so seeded and live + // values are the same union member, keeping downstream `=== 'idle'` checks + // correct. private applySeededAgentStatus(ptyId: string, title: string): void { if (!title) { return @@ -13468,6 +13537,11 @@ export class OrcaRuntimeService { this.setPairedRendererSessionOwnership(pty.ptyId, false) pty.disconnectedAt = Date.now() pty.lastExitCode = exitCode + // Why: the exited process's live frames say nothing about a replacement. + // A same-id respawn makes the leaf writable again before any new title, + // so leaving this true would let push delivery type into the new process + // on the dead one's idle. lastAgentStatus itself stays for `ps` display. + pty.lastAgentStatusObservedLive = false this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) } @@ -13485,6 +13559,7 @@ export class OrcaRuntimeService { leaf.connected = false leaf.writable = false leaf.lastExitCode = exitCode + leaf.lastAgentStatusObservedLive = false this.resolveExitWaiters(leaf) if (!preservesAbnormalSshSurface) { this.failActiveDispatchOnExit(leaf, exitCode) @@ -16744,7 +16819,22 @@ export class OrcaRuntimeService { result.available && recognizeAgentProcess(result.process) !== null ) { - this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + const restoredStatus = this.ptyTitleTrackersByPtyId + .get(ptyId) + ?.tracker.restoreLastAgentExit() + if (restoredStatus !== null && restoredStatus !== undefined) { + current.lastAgentStatus = restoredStatus + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastAgentStatus !== null) { + continue + } + // Why: the foreground agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. + leaf.lastAgentStatus = restoredStatus + if (restoredStatus === 'idle') { + this.deliverPendingMessagesForLeaf(leaf) + } + } + } return } this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) @@ -18253,12 +18343,7 @@ export class OrcaRuntimeService { isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink() })) - mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + sortDirEntries(mapped) return { resolvedPath: dirPath, entries: mapped, @@ -20963,7 +21048,8 @@ export class OrcaRuntimeService { private buildStartupForAgent( repo: Repo, agent: TuiAgent, - prompt: string | undefined + prompt: string | undefined, + launchPreferences?: AgentLaunchPreferences ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { if (!this.store) { throw new Error('runtime_unavailable') @@ -20981,12 +21067,15 @@ export class OrcaRuntimeService { isRemote, terminalWindowsShell: settings.terminalWindowsShell }) + const sessionOptions = this.toAgentSessionOptions(launchPreferences) const startupPlan = buildAgentStartupPlan({ agent, prompt: prompt ?? '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), platform: agentLaunchPlatform, shell: queuedShell, isRemote, @@ -21425,6 +21514,7 @@ export class OrcaRuntimeService { observeSetupCompletion?: boolean createdWithAgent?: TuiAgent startupAgent?: TuiAgent + startupLaunchPreferences?: AgentLaunchPreferences startupPrompt?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance @@ -21457,7 +21547,12 @@ export class OrcaRuntimeService { } const agentStartup = !args.startup && args.startupAgent - ? this.buildStartupForAgent(repo, args.startupAgent, args.startupPrompt) + ? this.buildStartupForAgent( + repo, + args.startupAgent, + args.startupPrompt, + args.startupLaunchPreferences + ) : null const draftStartup = !args.startup && !agentStartup && args.startupDraft @@ -24626,12 +24721,15 @@ export class OrcaRuntimeService { return opts } + const sessionOptions = this.toAgentSessionOptions(opts.launchPreferences) const startupPlan = buildAgentStartupPlan({ agent, prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), platform, shell: queuedShell, isRemote, @@ -25061,9 +25159,12 @@ export class OrcaRuntimeService { (Boolean(opts.agentSessionClaim) || (!requiresRendererFocus && opts.rendererBacked !== true) || // Why: `orca serve` exposes the local runtime without a renderer - // window. Renderer-backed Codex terminals are preferred for the app, - // but headless CLI users still need a usable terminal handle. - (opts.rendererBacked === true && rendererWindow === null)) + // window. Renderer-backed and focus-requested creates are preferred on + // the renderer, but with no window a background spawn is the only + // usable path — otherwise getAuthoritativeWindow() below throws and the + // caller gets no terminal at all (#10333). Focus is not lost: the + // spawned pane is still published and revealed with `activate`. + availableAuthoritativeWindow === null) if (shouldCreateInBackground) { if (!this.ptyController?.spawn) { @@ -28881,6 +28982,7 @@ export class OrcaRuntimeService { disconnectedAt: state.connected === false ? Date.now() : null, lastExitCode: null, lastAgentStatus: null, + lastAgentStatusObservedLive: false, lastOscTitle: null, lastOscTitleAt: null, lastOscTitleEpochMs: null, @@ -29190,6 +29292,7 @@ export class OrcaRuntimeService { this.restoredOrchestrationAuthorityByPtyId.delete(session.id) } pty.controllerTitle = session.title?.trim() || null + this.reconcileSubscriberDrivenProviderAttach(session.id) } // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. this.refreshPtyForegroundAgent(session.id) @@ -30929,6 +31032,10 @@ export class OrcaRuntimeService { return false } const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) + // Why: a bare `Cursor Agent` title is identity, not liveness — it reads the same + // whether cursor-agent is parked or long exited with the shell back. A null + // foreground is untracked, not alive, so no-evidence must stay a refusal. A live + // pane wrongly refused here means the read failed; fix that, not this. if (!fg) { return false } @@ -30990,6 +31097,8 @@ export class OrcaRuntimeService { return false } const fg = await this.ptyController.getForegroundProcess(pty.ptyId) + // Why: mirrors the leaf path — an unreadable foreground is indistinguishable from an + // exited one, so a bare Cursor identity title never substitutes for corroboration. if (!fg) { return false } @@ -31050,28 +31159,82 @@ export class OrcaRuntimeService { return this.getLeavesForPty(ptyId)[0] ?? null } - deliverPendingMessagesForHandle(handle: string): void { + deliverPendingMessagesForHandle(handle: string, reservedTypes?: ReadonlySet): void { + let terminalHandle = handle + if (!this.handles.has(terminalHandle)) { + const runId = handle.startsWith('run:') ? handle.slice('run:'.length) : '' + const coordinatorHandle = runId + ? this._orchestrationDb?.getRun(runId)?.coordinator_handle + : null + if (!coordinatorHandle || !this.handles.has(coordinatorHandle)) { + return + } + terminalHandle = coordinatorHandle + } try { - const { leaf } = this.getLiveLeafForHandle(handle) - if (leaf.lastAgentStatus === 'idle') { - this.deliverPendingMessages(leaf) + const { leaf } = this.getLiveLeafForHandle(terminalHandle) + // Why lastAgentStatusObservedLive: a cold restore seeds `idle` from the + // title persisted at snapshot time, so an agent that went busy across the + // relaunch still reads idle until its first live frame. Pushing on that + // would type a message plus Enter into a working agent. Seeded state waits + // for a live observation to authorize it. + if (leaf.lastAgentStatus === 'idle' && leaf.lastAgentStatusObservedLive) { + this.deliverPendingMessages(leaf, { mailboxHandle: handle, reservedTypes }) } } catch { - // Unknown/stale handles can't be pushed now; the persisted message stays available via explicit check or future idle delivery. + // Unknown/stale handles can't be pointed now; the persisted message stays available via explicit check or future idle delivery. + } + } + + private deliverPendingMessagesForLeaf(leaf: RuntimeLeafRecord): void { + this.deliverPendingMessages(leaf) + if (!this._orchestrationDb) { + return + } + const run = this._orchestrationDb.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) + if (run) { + this.deliverPendingMessages(leaf, { mailboxHandle: `run:${run.id}` }) } } // Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling. notifyMessageArrived(handle: string, messageType?: string): void { + // Why: push-on-idle is driven by status transitions; a message that + // arrives while the recipient is already idle never sees a transition, so + // deliver now (#12536). deliverPendingMessagesForHandle no-ops when the + // leaf is not idle. Main's messageDeliveryFlights serialize mid-Enter + // re-notifies without a separate settle barrier. + // Why skip when a waiter will consume this: a blocked check owns the row, + // so also pointing and submitting the pane would wake it twice. The pull wins. + // Why "will consume" and not "exists": a waiter filtered to other types + // never returns this row — check re-reads under the same filter on timeout + // — so treating it as the consumer strands the message in exactly the + // already-idle state #12536 is about. const waiters = this.messageWaitersByHandle.get(handle) - if (!waiters || waiters.size === 0) { + // Why: don't wake a coordinator waiting for worker_done/escalation on heartbeat noise it would misread as idleness. + const consumers = waiters + ? [...waiters].filter( + (waiter) => !messageType || !waiter.typeFilter || waiter.typeFilter.includes(messageType) + ) + : [] + if (consumers.length === 0) { + // Why snapshot the reservation here: every remaining waiter filters this + // type out, but the push reads ALL pending rows. A waiter resolved later in + // this same drain is gone by the time the push runs, so reading waiters + // then would miss what its check is about to return. Captured now, the + // types those waiters claim stay out of the batch. + const reservedTypes = new Set(waiters ? [...waiters].flatMap((w) => w.typeFilter ?? []) : []) + // Why queueMicrotask: resolveMessageWaiter removes the waiter synchronously + // but its check handler marks the rows read a microtask later. Two sends + // that resumed adjacently off one shared in-flight promise (group send + // awaits listTerminals) put a no-waiter notify inside that window, where a + // synchronous push would inject rows the resolved check is about to return. + // Deferring one hop puts the push behind any already-queued check, and it + // re-reads undelivered rows when it runs so nothing strands. + queueMicrotask(() => this.deliverPendingMessagesForHandle(handle, reservedTypes)) return } - for (const waiter of [...waiters]) { - // Why: don't wake a coordinator waiting for worker_done/escalation on heartbeat noise it would misread as idleness. - if (messageType && waiter.typeFilter && !waiter.typeFilter.includes(messageType)) { - continue - } + for (const waiter of consumers) { this.resolveMessageWaiter(waiter, 'notified') } } @@ -31659,10 +31822,8 @@ export class OrcaRuntimeService { return null } - // Why: delivered_at for Claude targets stamps only in the delayed-Enter - // callback, so the whole write→settle span must be single-flight per pty — - // a second read inside it would re-inject the same unread rows. Triggers - // landing mid-flight park the latest leaf and re-run once on settle. The + // Why: the whole pointer→Enter span must be single-flight per pty. Triggers + // landing mid-flight park their mailbox and re-run once on settle. The // flight object is the settle identity: a stale settle surviving an exit // retire must not clear a newer same-id flight or flush its parked trigger. private readonly messageDeliveryFlightsByPtyId = new Map< @@ -31670,7 +31831,10 @@ export class OrcaRuntimeService { { enterTimer: ReturnType | null } >() - private readonly parkedMessageRedeliveryLeavesByPtyId = new Map() + private readonly parkedMessageRedeliveriesByPtyId = new Map< + string, + Map }> + >() private settlePendingMessageDelivery( ptyId: string, @@ -31680,28 +31844,48 @@ export class OrcaRuntimeService { return } this.messageDeliveryFlightsByPtyId.delete(ptyId) - const parkedLeaf = this.parkedMessageRedeliveryLeavesByPtyId.get(ptyId) - if (!parkedLeaf) { + const parked = this.parkedMessageRedeliveriesByPtyId.get(ptyId) + if (!parked) { return } - this.parkedMessageRedeliveryLeavesByPtyId.delete(ptyId) - this.deliverPendingMessages(parkedLeaf) + this.parkedMessageRedeliveriesByPtyId.delete(ptyId) + for (const [mailboxHandle, delivery] of parked) { + this.deliverPendingMessages(delivery.leaf, { + mailboxHandle, + reservedTypes: delivery.reservedTypes + }) + } } - // Why: an Enter armed for a dead session must not fire into a same-id cold - // restore — it would inject \r and stamp rows the replacement never saw. - // Retire without stamping; the rows re-deliver on the replacement's next idle. + // Why: a dead session's Enter or watermark must not affect a same-id cold restore. private retirePendingMessageDeliveryForPty(ptyId: string): void { const flight = this.messageDeliveryFlightsByPtyId.get(ptyId) if (flight?.enterTimer != null) { clearTimeout(flight.enterTimer) } this.messageDeliveryFlightsByPtyId.delete(ptyId) - this.parkedMessageRedeliveryLeavesByPtyId.delete(ptyId) + this.parkedMessageRedeliveriesByPtyId.delete(ptyId) + for (const leaf of this.getLeavesForPty(ptyId)) { + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (handle) { + this.lastPointedMessageSequenceByHandle.delete(handle) + } + const run = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) + if (run) { + this.lastPointedMessageSequenceByHandle.delete(`run:${run.id}`) + } + } } // Why: push-on-idle delivery is event-driven (no polling) because the runtime owns both the message store and terminal status detection. - private deliverPendingMessages(leaf: RuntimeLeafRecord, skipAbsenceProbe = false): void { + private deliverPendingMessages( + leaf: RuntimeLeafRecord, + options: { + mailboxHandle?: string + reservedTypes?: ReadonlySet + skipAbsenceProbe?: boolean + } = {} + ): void { if (!this._orchestrationDb) { return } @@ -31710,14 +31894,36 @@ export class OrcaRuntimeService { if (!handle) { return } + const mailboxHandle = options.mailboxHandle ?? handle - // Why before reading rows: rows read mid-flight are the not-yet-stamped ones. if (leaf.ptyId && this.messageDeliveryFlightsByPtyId.has(leaf.ptyId)) { - this.parkedMessageRedeliveryLeavesByPtyId.set(leaf.ptyId, leaf) + let parked = this.parkedMessageRedeliveriesByPtyId.get(leaf.ptyId) + if (!parked) { + parked = new Map() + this.parkedMessageRedeliveriesByPtyId.set(leaf.ptyId, parked) + } + const priorReservedTypes = parked.get(mailboxHandle)?.reservedTypes + const reservedTypes = + priorReservedTypes || options.reservedTypes + ? new Set([...(priorReservedTypes ?? []), ...(options.reservedTypes ?? [])]) + : undefined + parked.set(mailboxHandle, { leaf, reservedTypes }) return } - const unread = this._orchestrationDb.getUndeliveredUnreadMessages(handle) + // Why filter here and not at the trigger: the push reads every pending row, + // not just the one that woke it, so a row a pull has claimed would be typed + // into the pane AND returned by that pull's check. Live waiters cover the + // still-blocked case; reservedTypes carries the notify-time snapshot for a + // waiter resolved later in the same drain, which is already gone from the map. + const waiters = this.messageWaitersByHandle.get(mailboxHandle) + const unread = this._orchestrationDb + .getUndeliveredUnreadMessages(mailboxHandle) + .filter( + (message) => + !options.reservedTypes?.has(message.type) && + !messageTypeHasLiveWaiter(waiters, message.type) + ) if (unread.length === 0) { return } @@ -31725,9 +31931,16 @@ export class OrcaRuntimeService { if (!leaf.writable || !leaf.ptyId) { return } + const newestSequence = unread.at(-1)?.sequence + if ( + newestSequence === undefined || + newestSequence <= (this.lastPointedMessageSequenceByHandle.get(mailboxHandle) ?? -1) + ) { + return + } if ( - !skipAbsenceProbe && + !options.skipAbsenceProbe && this.ptyController?.probePtyLiveness && !this.controllerKnowsPtyIsLive(leaf.ptyId) ) { @@ -31736,8 +31949,7 @@ export class OrcaRuntimeService { // them queued for a future surface; unknown liveness still delivers. const probedPtyId = leaf.ptyId // Why: triggers arriving mid-probe must not each arm a continuation — the - // Claude Enter delay stamps delivered_at late, so every continuation would - // re-read the same unread rows and double-deliver. The single armed + // Every continuation would re-read the same unread rows. The single armed // continuation re-reads fresh rows when it fires, so nothing is lost. if (this.probeDeferredDeliveryPtyIds.has(probedPtyId)) { return @@ -31747,7 +31959,32 @@ export class OrcaRuntimeService { .then((absent) => { this.probeDeferredDeliveryPtyIds.delete(probedPtyId) if (!absent && leaf.ptyId === probedPtyId) { - this.deliverPendingMessages(leaf, true) + // Why a macrotask and not the stale reservation snapshot: a `remote:` + // pty answers the probe null before its first await, so this chain can + // settle in microtasks and overtake the resumption of a check resolved + // meanwhile — that check's waiter is already out of the map and its + // rows are not yet read, so the push would inject what it returns. + // Yielding the turn lets every queued check mark its rows read first; + // re-reading then (rather than replaying a reservation this probe may + // have outlived) is what keeps an orphaned row from stranding. + setTimeout(() => { + // Why current state, not the closure: the gate that authorized this + // push ran before the probe. A same-id cold restore inside the probe + // window keeps ptyId identical and makes the leaf writable again, so + // an id-only check would type the pointer plus Enter into a process + // whose idle was never observed. Re-read the live-idle gate. + const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if ( + currentLeaf?.ptyId === probedPtyId && + currentLeaf.lastAgentStatus === 'idle' && + currentLeaf.lastAgentStatusObservedLive + ) { + this.deliverPendingMessages(currentLeaf, { + mailboxHandle, + skipAbsenceProbe: true + }) + } + }, 0) } }) .catch(() => { @@ -31759,32 +31996,25 @@ export class OrcaRuntimeService { const deliveryPtyId = leaf.ptyId const flight: { enterTimer: ReturnType | null } = { enterTimer: null } this.messageDeliveryFlightsByPtyId.set(deliveryPtyId, flight) - // Why: every sync outcome — failed write, sync-stamped branch, or a throw — + // Why: every sync outcome — failed write, Cursor branch, or a throw — // must end the flight here, or a leaked flag parks this pty's deliveries // forever. Only an armed Enter hands settling to its own callback. let settlesInEnterCallback = false try { - const payload = formatMessagesForInjection(unread) + const payload = formatMessagePointer(unread.length) const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false if (!wrote) { return } - - // The active coordinator prompt is user-owned input, so push-on-idle must not synthesize Enter. - if (this._orchestrationDb.getActiveCoordinatorRun()?.coordinator_handle === handle) { - this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) - return - } + this.lastPointedMessageSequenceByHandle.set(mailboxHandle, newestSequence) const tabTitle = this.tabs.get(leaf.tabId)?.title if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) { // Why: Cursor Agent treats injected PTY text as editable prompt input, so submitting must stay under user control. - this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) return } - // Why: Claude Code treats a large PTY write as a paste and swallows a \r in the same write; send Enter separately after a delay, stamping delivered_at only once \r is confirmed. - // Important (design doc §3.2, feedback #2): stamp delivered_at, not read — read means "a check-caller consumed this"; flipping it would hide the message from check --unread. + // Why: agent TUIs can swallow a \r in the same PTY write; submit separately after a delay. flight.enterTimer = setTimeout(() => { try { // Why current state, not the closure: graph resync replaces leaf @@ -31797,12 +32027,9 @@ export class OrcaRuntimeService { if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - const submitted = this.ptyController?.write(deliveryPtyId, '\r') ?? false - if (submitted) { - this._orchestrationDb?.markAsDelivered(unread.map((m) => m.id)) - } + this.ptyController?.write(deliveryPtyId, '\r') } catch { - // Terminal may have closed during the delay — messages stay queued (delivered_at NULL) and re-deliver on next idle. + // Terminal may have closed during the delay; mail remains queued for check. } finally { // Why finally: every outcome — submit, refusal, throw — ends the flight, // and settle re-runs any trigger parked during it so nothing strands. diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index b4732f04ac2..f7d9c14d880 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatMessageBanner, formatMessagesForInjection } from './formatter' +import { formatMessageBanner, formatMessagePointer, formatMessagesForInjection } from './formatter' import type { MessageRow } from './types' function makeMessage(overrides: Partial = {}): MessageRow { @@ -183,3 +183,15 @@ describe('formatMessagesForInjection', () => { expect(result).toContain(`${bannerA}\n\n${bannerB}`) }) }) + +describe('formatMessagePointer', () => { + it('formats a singular pointer without message content', () => { + expect(formatMessagePointer(1)).toBe( + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + }) + + it('pluralizes a batched pointer', () => { + expect(formatMessagePointer(3)).toContain('3 orchestration messages') + }) +}) diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index 269ba29302f..678c5e08499 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -107,3 +107,8 @@ export function formatMessagesForInjection(messages: MessageRow[]): string { const banners = messages.map(formatMessageBanner).join('\n\n') return `\n--- Orchestration Messages (${messages.length}) ---\n${banners}\n---\n` } + +export function formatMessagePointer(count: number): string { + const noun = count === 1 ? 'message' : 'messages' + return `\nYou have ${count} orchestration ${noun}. Run \`orca orchestration check\`.\n` +} diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index ddd0541c57b..23a6caaed42 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -137,6 +137,25 @@ describe('mapRuntimeError', () => { }) }) + it('does not recommend a blind retry after a coordinate press may have landed', () => { + const message = + 'coordinate click aborted because the recipient changed; 1 press(es) may already have been delivered' + const error = Object.assign(new Error(message), { code: 'window_not_focused' }) + + const response = mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, error) + + expect(response.error).toMatchObject({ + code: 'window_not_focused', + message, + data: { + nextSteps: [ + expect.stringContaining('verify whether the intended action already occurred'), + expect.stringContaining('Do not retry the click if it already took effect') + ] + } + }) + }) + it('preserves structured lineage error codes and data for CLI recovery hints', () => { const response = mapRuntimeError( 'req_1', diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 930a056a6ac..a2e02e6ccc4 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -108,7 +108,7 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow COMPUTER_PASSTHROUGH_CODES.has((error as { code: string }).code) ) { const code = (error as { code: string }).code - return errorResponse(id, meta, code, message, computerErrorData(code)) + return errorResponse(id, meta, code, message, computerErrorData(code, message)) } if ( error instanceof Error && diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 606972f4249..7e053daa613 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -186,6 +186,22 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) }) + it('keeps completed scans cached for one minute', async () => { + vi.useFakeTimers({ now: new Date('2026-08-05T00:00:00.000Z') }) + try { + await listAiVaultSessions({ limit: 500 }) + await vi.advanceTimersByTimeAsync(59_999) + await listAiVaultSessions({ limit: 500 }) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + await listAiVaultSessions({ limit: 500 }) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it('serves lower depths from a larger completed scan', async () => { await listAiVaultSessions({ limit: 1000 }) await listAiVaultSessions({ limit: 250 }) diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 7e13e584c42..0fe273108a5 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -10,7 +10,9 @@ export const WorktreeSelector = z.object({ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional(), bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), - reuseLineStats: z.boolean().optional() + reuseLineStats: z.boolean().optional(), + // Shape is re-validated host-side before it reaches a git argv. + branchLineTotalMergeBase: z.string().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index c4ccdf071b6..c30e80f4338 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -93,6 +93,7 @@ export const GIT_METHODS: RpcMethod[] = [ params.includeIgnored === undefined && params.bypassEffectiveUpstreamNegativeCache === undefined && params.reuseLineStats === undefined && + params.branchLineTotalMergeBase === undefined && signal === undefined ? undefined : { @@ -103,6 +104,9 @@ export const GIT_METHODS: RpcMethod[] = [ ? { bypassEffectiveUpstreamNegativeCache: true } : {}), ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(params.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: params.branchLineTotalMergeBase }), ...(signal ? { signal } : {}) } return options === undefined diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 538dadc96d0..23a6dc0928f 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -11,6 +11,14 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' import { OrchestrationError } from '../../orchestration/orchestration-error' import type { WorkerStartInput } from './orchestration-worker-start-schema' +import { + assertWorkerLaunchPreferencesRuntimeSupported, + assertWorkerLaunchPreferencesCreateTerminal, + createPendingWorkerLaunchReceipt, + resolveFederatedWorkerLaunchReceipt, + type OrchestrationWorkerLaunchReceipt +} from './orchestration-worker-launch-preferences' +import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation' export async function startFederatedWorker(args: { params: WorkerStartInput @@ -40,7 +48,13 @@ export async function startFederatedWorker(args: { ) } const createsWorktree = worktree === 'new-top-level' - validateRemoteWorkerStart(params, createsWorktree) + assertWorkerLaunchPreferencesCreateTerminal(params) + validateFederatedWorkerStartPlacement(params, createsWorktree) + const requestedLaunch = createPendingWorkerLaunchReceipt({ + agent: isTuiAgent(params.agent) ? params.agent : null, + model: params.model, + effort: params.effort + }) const server = runtime.resolveOrchestrationWorkerServer(params.on as string) const status = (await runtime.callOrchestrationWorkerServer( @@ -62,6 +76,12 @@ export async function startFederatedWorker(args: { `Connected server ${server.name} does not support orchestration federation.` ) } + assertWorkerLaunchPreferencesRuntimeSupported({ + model: params.model, + effort: params.effort, + capabilities: status.capabilities, + serverName: server.name + }) const federationProtocolVersion = status.capabilities?.includes( ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY ) @@ -81,6 +101,7 @@ export async function startFederatedWorker(args: { baseBranch: params.baseBranch ?? null, terminal: params.terminal ?? null, agent: params.agent ?? null, + launch: requestedLaunch, timeoutMs: params.timeoutMs ?? 60_000, setup: setupDecision, setupSource: createsWorktree @@ -122,6 +143,8 @@ export async function startFederatedWorker(args: { : undefined, terminal: params.terminal, agent: params.agent, + model: params.model, + effort: params.effort, timeoutMs: params.timeoutMs, devMode: params.devMode }, @@ -134,6 +157,11 @@ export async function startFederatedWorker(args: { 'The worker server returned a different Dispatch attachment.' ) } + const launch = resolveFederatedWorkerLaunchReceipt( + remote.launch, + requestedLaunch, + remote.state === 'ready' + ) if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) { db.updateFederatedDispatchResources({ dispatchId: started.dispatch.id, @@ -160,6 +188,7 @@ export async function startFederatedWorker(args: { stage: readyWorker.stage, server: { environmentId: server.environmentId, name: server.name }, setup: remote.setup, + launch, timeoutMs: params.timeoutMs ?? 60_000, effects: remote.effects ?? [], residualResources: remote.residualResources ?? [] @@ -171,7 +200,7 @@ export async function startFederatedWorker(args: { remote.failedStage ?? 'remote_attach', remote.lastError ?? 'The worker server reported an unknown start outcome.' ) - return federatedUnknownReceipt(worker, task.id, server.name) + return federatedUnknownReceipt(worker, task.id, server.name, launch) } const worker = db.failWorkerStart( started.dispatch.id, @@ -188,6 +217,7 @@ export async function startFederatedWorker(args: { failedStage: worker.stage, lastError: worker.last_error, setup: remote.setup, + launch, effects: remote.effects ?? [], residualResources: remote.residualResources ?? [] } @@ -204,12 +234,13 @@ export async function startFederatedWorker(args: { server: { environmentId: server.environmentId, name: server.name }, failedStage: worker.stage, lastError: worker.last_error, + launch: requestedLaunch, effects: [], residualResources: [] } } const worker = db.markWorkerStartUnknown(started.dispatch.id, 'remote_attach', reason) - return federatedUnknownReceipt(worker, task.id, server.name) + return federatedUnknownReceipt(worker, task.id, server.name, requestedLaunch) } } @@ -220,45 +251,13 @@ type RemoteStartReceipt = { worktreeId?: string terminalHandle?: string setup?: { state: string } + launch?: OrchestrationWorkerLaunchReceipt effects?: unknown[] residualResources?: unknown[] failedStage?: string lastError?: string } -function validateRemoteWorkerStart(params: WorkerStartInput, createsWorktree: boolean): void { - if (createsWorktree && (!params.name || !params.repo)) { - throw new OrchestrationError( - 'invalid_argument', - 'Remote new-top-level requires --name and an explicit --repo from remote discovery.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with remote new-worktree creation.' - ) - } - if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to remote new-top-level worktrees.' - ) - } - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when remote worker-start creates a terminal.' - ) - } -} - function isKnownRemoteStartFailure(code: string): boolean { return [ 'invalid_argument', @@ -272,7 +271,8 @@ function isKnownRemoteStartFailure(code: string): boolean { function federatedUnknownReceipt( worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, taskId: string, - serverName: string + serverName: string, + launch: OrchestrationWorkerLaunchReceipt ): unknown { return { taskId, @@ -280,6 +280,7 @@ function federatedUnknownReceipt( state: 'outcome_unknown', stage: worker.stage, server: { name: serverName }, + launch, failedStage: worker.stage, lastError: worker.last_error, effects: [], diff --git a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts index 15b2194140a..486821231e1 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts @@ -61,7 +61,9 @@ describe('federated worker agent launch', () => { taskSpec: 'remote cursor worker', protocolVersion: 1, worktree: 'id:repo::remote-worktree', - agent: 'cursor' + agent: 'cursor', + model: 'gpt-5.3-codex', + effort: 'high' }), { runtime, @@ -72,14 +74,28 @@ describe('federated worker agent launch', () => { payloadHash: 'remote_payload' } } - )) as { state: string; failedStage?: string; lastError?: string } + )) as { + state: string + failedStage?: string + lastError?: string + launch: unknown + } // Why: assert the worker actually reached ready — a spy-only assertion would // stay green even if every stage after terminal_create regressed. - expect(result).toMatchObject({ state: 'ready' }) + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' }, + effective: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' } + } + }) expect(createTerminal).toHaveBeenCalledWith( 'id:repo::remote-worktree', - expect.objectContaining({ startupAgent: 'cursor' }) + expect.objectContaining({ + startupAgent: 'cursor', + launchPreferences: { model: 'gpt-5.3-codex', effort: 'high' } + }) ) expect(createTerminal).toHaveBeenCalledWith( 'id:repo::remote-worktree', diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts index b7e2da76531..fa9b60facba 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts @@ -1,6 +1,7 @@ import type { OrchestrationDb } from '../../orchestration/db' import { isFederationEffectUnknown } from './orchestration-federation-effects' import type { WorkerSetupReceipt } from './orchestration-worker-topology' +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' export function failFederatedAttachmentWithReceipt(args: { db: OrchestrationDb @@ -9,6 +10,7 @@ export function failFederatedAttachmentWithReceipt(args: { failedStage: string error: unknown setup: WorkerSetupReceipt + launch: OrchestrationWorkerLaunchReceipt }): unknown { const reason = args.error instanceof Error ? args.error.message : String(args.error) const unknown = isFederationEffectUnknown(args.error, args.failedStage) @@ -26,6 +28,7 @@ export function failFederatedAttachmentWithReceipt(args: { failedStage: args.failedStage, lastError: reason, setup: args.setup, + launch: args.launch, effects: JSON.parse(attachment.effects) as unknown[], residualResources: JSON.parse(attachment.residual_resources) as unknown[] } diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts index a657ff7d09c..e6bea35c20a 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { OptionalWorkerLaunchPreference } from './orchestration-worker-start-schema' export const FederationAttachStartParams = z.object({ dispatchId: requiredString('Missing Dispatch ID'), @@ -16,6 +17,10 @@ export const FederationAttachStartParams = z.object({ setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), terminal: OptionalString, agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, timeoutMs: OptionalFiniteNumber, devMode: z.boolean().optional() }) + +export type FederationAttachStartInput = z.infer diff --git a/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts b/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts new file mode 100644 index 00000000000..7d004775310 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts @@ -0,0 +1,25 @@ +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import type { RpcRequest } from '../core' + +export function createFederationWorkerStartRequest( + taskId: string, + overrides: Record = {} +): RpcRequest { + return { + id: 'rpc_worker_start', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_windows_worker', + method: 'orchestration.workerStart', + params: { + task: taskId, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'windows-audit', + agent: 'codex', + ...overrides + } + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation.test.ts b/src/main/runtime/rpc/methods/orchestration-federation.test.ts index cf9224c875c..e797a09c256 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.test.ts @@ -8,8 +8,8 @@ import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' import { RpcDispatcher } from '../dispatcher' -import type { RpcRequest } from '../core' import { ORCHESTRATION_METHODS } from './orchestration' +import { createFederationWorkerStartRequest as startRequest } from './orchestration-federation-test-request' describe('orchestration federation', () => { const databases: OrchestrationDb[] = [] @@ -97,26 +97,6 @@ describe('orchestration federation', () => { return homeDb.createTask({ spec: 'Audit Windows behavior', runId: run.id }) } - function startRequest(taskId: string, overrides: Record = {}): RpcRequest { - return { - id: 'rpc_worker_start', - authToken: 'coordinator-token', - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, - orchestrationRequestId: 'request_windows_worker', - method: 'orchestration.workerStart', - params: { - task: taskId, - from: 'term_coord', - on: 'windows', - worktree: 'new-top-level', - repo: 'id:windows-repo', - name: 'windows-audit', - agent: 'codex', - ...overrides - } - } - } - function configureWorkerRuntime(runtime: OrcaRuntimeService): void { vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) vi.spyOn(runtime, 'showRepo').mockResolvedValue({ @@ -230,6 +210,25 @@ describe('orchestration federation', () => { ) }) + it('does not report remotely rejected preferences as effective', async () => { + const task = createHomeTask() + + const response = await homeDispatcher.dispatch( + startRequest(task.id, { agent: 'grok', model: 'unsupported-model' }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { + state: 'failed', + launch: { + requested: { agent: 'grok', model: 'unsupported-model', effort: null }, + effective: null + } + } + }) + }) + it('preserves wait-for-setup gating on the connected worker server', async () => { vi.mocked(workerRuntime.createManagedWorktree).mockResolvedValueOnce({ worktree: { id: 'repo::windows-worktree', repoId: 'repo' }, diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index ce173290848..ffde742ecee 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -1,4 +1,3 @@ -import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -18,6 +17,7 @@ import { } from './orchestration-federation-setup' import { FederationAttachStartParams } from './orchestration-federation-start-schema' import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt' +import { prepareFederationAttachmentWorkerStart } from './orchestration-worker-start-validation' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ defineMethod({ @@ -37,43 +37,11 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ ) } const createsWorktree = params.worktree === 'new-top-level' - if (createsWorktree && (!params.name || !params.repo)) { - throw new OrchestrationError( - 'invalid_argument', - 'A remote new-top-level worktree requires --name and an explicit --repo.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with remote new-worktree creation.' - ) - } - if ( - !createsWorktree && - (params.name || params.repo || params.baseBranch || params.setup || params.setupSource) - ) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to remote new-top-level worktrees.' - ) - } - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - const agent = params.agent - if (!params.terminal && (!agent || !isTuiAgent(agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when federated worker-start creates a terminal.' - ) - } - if (agent) { - runtime.validateOrchestrationAgentLauncher(agent as TuiAgent) - } + const { agent, launch } = prepareFederationAttachmentWorkerStart({ + params, + createsWorktree, + runtime + }) if (createsWorktree) { await assertOrchestrationWorktreeCreationSupported({ runtime, @@ -126,6 +94,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ observeSetupCompletion: true, createdWithAgent: agent as TuiAgent, startupAgent: agent as TuiAgent, + ...(launch.preferences ? { startupLaunchPreferences: launch.preferences } : {}), activate: false, lineage: { noParent: true } }) @@ -196,6 +165,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ // Why: agent ids are not shell commands (`cursor` is the desktop app, // its CLI is `cursor-agent`); resolve through the TUI agent config. startupAgent: agent as TuiAgent, + ...(launch.preferences ? { launchPreferences: launch.preferences } : {}), title: `worker-${params.taskId}`, presentation: 'background' }) @@ -284,6 +254,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ worktreeId: worktree.id, terminalHandle, setup, + launch: launch.receipt, effects, residualResources: [] } @@ -294,7 +265,8 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ runtimeEpoch: runtime.getRuntimeId(), failedStage, error, - setup + setup, + launch: launch.receipt }) } } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts new file mode 100644 index 00000000000..dd17945445c --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + assertWorkerLaunchPreferencesCreateTerminal, + assertWorkerLaunchPreferencesRuntimeSupported, + createPendingWorkerLaunchReceipt, + resolveFederatedWorkerLaunchReceipt, + resolveWorkerLaunchPreferences +} from './orchestration-worker-launch-preferences' +import { WorkerStartParams } from './orchestration-worker-start-schema' + +describe('orchestration worker launch preferences', () => { + it('passes an opaque Claude model and portable effort through the shared catalog', () => { + expect( + resolveWorkerLaunchPreferences({ + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + }) + ).toEqual({ + preferences: { model: 'aws-bedrock-opus-5', effort: 'high' }, + receipt: { + requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }, + effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' } + } + }) + }) + + it('does not invent an effort when only a model is requested', () => { + expect( + resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-sol' }).preferences + ).toEqual({ model: 'gpt-5.6-sol' }) + }) + + it('rejects model-specific effort combinations the catalog disproves', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'codex', + model: 'gpt-5.6-luna', + effort: 'xhigh' + }) + ).toThrow('does not support effort xhigh') + }) + + it('rejects effort without a model', () => { + expect(() => resolveWorkerLaunchPreferences({ agent: 'codex', effort: 'high' })).toThrow( + '--effort requires --model' + ) + }) + + it('rejects model selection for agents without a launch catalog', () => { + expect(() => + resolveWorkerLaunchPreferences({ agent: 'grok', model: 'grok-code-fast-1' }) + ).toThrow('does not support launch-time model selection') + }) + + it('does not expose deprecated Gemini model selection to worker-start', () => { + expect(() => + resolveWorkerLaunchPreferences({ agent: 'gemini', model: 'gemini-3-pro-preview' }) + ).toThrow('does not support launch-time model selection') + }) + + it('rejects preferences when reusing an existing terminal', () => { + expect(() => + assertWorkerLaunchPreferencesCreateTerminal({ + terminal: 'term_existing', + model: 'gpt-5.6-sol' + }) + ).toThrow('cannot be applied when reusing an existing terminal') + }) + + it('requires remote capability support only for explicit preferences', () => { + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + model: 'gpt-5.6-sol', + capabilities: [], + serverName: 'windows' + }) + ).toThrow('does not support worker model or effort overrides') + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + capabilities: [], + serverName: 'windows' + }) + ).not.toThrow() + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + model: 'gpt-5.6-sol', + capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY], + serverName: 'windows' + }) + ).not.toThrow() + }) + + it('uses the requested launch receipt when an older worker omits it', () => { + const requested = createPendingWorkerLaunchReceipt({ + agent: 'codex', + model: 'gpt-5.6-sol', + effort: 'high' + }) + + expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, true)).toEqual({ + requested: requested.requested, + effective: requested.requested + }) + expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, false)).toBe(requested) + }) + + it.each([' custom-model', 'custom-model '])( + 'rejects model ids with surrounding whitespace: %j', + (model) => { + expect(WorkerStartParams.safeParse({ task: 'task_1', agent: 'codex', model }).success).toBe( + false + ) + } + ) + + it('bounds opaque launch preferences', () => { + expect( + WorkerStartParams.safeParse({ + task: 'task_1', + agent: 'codex', + model: 'm'.repeat(513) + }).success + ).toBe(false) + expect( + WorkerStartParams.safeParse({ + task: 'task_1', + agent: 'codex', + model: 'custom-model', + effort: 'e'.repeat(513) + }).success + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts new file mode 100644 index 00000000000..76c60064691 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts @@ -0,0 +1,158 @@ +import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' +import { + findCatalogModel, + findCatalogOption, + getAgentSessionOptionCatalog +} from '../../../../shared/agent-session-option-catalog' +import { resolveAgentSessionOptionLaunch } from '../../../../shared/agent-session-option-launch' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { TuiAgent } from '../../../../shared/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export type OrchestrationWorkerLaunchSelection = { + agent: TuiAgent | null + model: string | null + effort: string | null +} + +export type OrchestrationWorkerLaunchReceipt = { + requested: OrchestrationWorkerLaunchSelection + effective: OrchestrationWorkerLaunchSelection | null +} + +export function createWorkerLaunchReceipt(args: { + agent: TuiAgent | null + model?: string + effort?: string +}): OrchestrationWorkerLaunchReceipt { + const selection = { + agent: args.agent, + model: args.model ?? null, + effort: args.effort ?? null + } + return { requested: selection, effective: { ...selection } } +} + +export function createPendingWorkerLaunchReceipt(args: { + agent: TuiAgent | null + model?: string + effort?: string +}): OrchestrationWorkerLaunchReceipt { + return { + requested: { + agent: args.agent, + model: args.model ?? null, + effort: args.effort ?? null + }, + effective: null + } +} + +export function resolveWorkerLaunchPreferences(args: { + agent: TuiAgent + model?: string + effort?: string +}): { + preferences: AgentLaunchPreferences | undefined + receipt: OrchestrationWorkerLaunchReceipt +} { + if (args.effort && !args.model) { + throw new OrchestrationError('invalid_argument', '--effort requires --model.') + } + if (!args.model) { + return { + preferences: undefined, + receipt: createWorkerLaunchReceipt({ agent: args.agent }) + } + } + + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} does not support launch-time model selection.` + ) + } + + if (args.effort) { + const model = findCatalogModel(catalog, args.model) + const option = + findCatalogOption(model, 'effort') ?? + (!model + ? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort') + : undefined) + if ( + option?.kind.type !== 'select' || + !option.kind.choices.some((choice) => choice.value === args.effort) + ) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.` + ) + } + } + + const requested = { + model: args.model, + ...(args.effort ? { effort: args.effort } : {}) + } + const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false) + if ( + resolved.appliedValues.model !== args.model || + resolved.appliedValues.effort !== args.effort + ) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} cannot apply the requested worker launch preferences.` + ) + } + + const preferences: AgentLaunchPreferences = requested + return { + preferences, + receipt: createWorkerLaunchReceipt({ agent: args.agent, ...preferences }) + } +} + +export function assertWorkerLaunchPreferencesCreateTerminal(args: { + terminal?: string + model?: string + effort?: string +}): void { + if (args.terminal && (args.model || args.effort)) { + throw new OrchestrationError( + 'invalid_argument', + '--model and --effort cannot be applied when reusing an existing terminal.' + ) + } +} + +export function assertWorkerLaunchPreferencesRuntimeSupported(args: { + model?: string + effort?: string + capabilities?: readonly string[] + serverName: string +}): void { + if ( + (args.model || args.effort) && + !args.capabilities?.includes(ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY) + ) { + throw new OrchestrationError( + 'capability_unsupported', + `Connected server ${args.serverName} does not support worker model or effort overrides.` + ) + } +} + +export function resolveFederatedWorkerLaunchReceipt( + remote: OrchestrationWorkerLaunchReceipt | undefined, + requested: OrchestrationWorkerLaunchReceipt, + remoteReady: boolean +): OrchestrationWorkerLaunchReceipt { + if (remote) { + return remote + } + return remoteReady + ? { requested: requested.requested, effective: { ...requested.requested } } + : requested +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts index 167d4610984..e58f9fff01e 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts @@ -3,6 +3,7 @@ import { isUnknownWorkerStartOutcome, type WorkerSetupReceipt } from './orchestration-worker-topology' +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' export function failWorkerStartWithReceipt(args: { db: OrchestrationDb @@ -12,6 +13,7 @@ export function failWorkerStartWithReceipt(args: { failedStage: string error: unknown setup: WorkerSetupReceipt + launch: OrchestrationWorkerLaunchReceipt }): unknown { const reason = args.error instanceof Error ? args.error.message : String(args.error) const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) @@ -27,6 +29,7 @@ export function failWorkerStartWithReceipt(args: { failedStage: args.failedStage, lastError: reason, setup: args.setup, + launch: args.launch, effects: JSON.parse(worker.effects) as unknown[], residualResources: JSON.parse(worker.residual_resources) as unknown[], ...(unknown diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts index d577598c966..cf081f9b36c 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts @@ -1,6 +1,13 @@ import { z } from 'zod' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +export const OptionalWorkerLaunchPreference = z + .string() + .min(1) + .max(512) + .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid') + .optional() + export const WorkerStartParams = z.object({ task: requiredString('Missing --task'), on: OptionalString, @@ -15,6 +22,8 @@ export const WorkerStartParams = z.object({ setup: z.enum(['run', 'skip', 'inherit']).optional(), terminal: OptionalString, agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, retryOf: OptionalString, timeoutMs: OptionalFiniteNumber, devMode: z.boolean().optional() diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts new file mode 100644 index 00000000000..f6bb103f37e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts @@ -0,0 +1,164 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { FederationAttachStartInput } from './orchestration-federation-start-schema' +import { + assertWorkerLaunchPreferencesCreateTerminal, + createWorkerLaunchReceipt, + resolveWorkerLaunchPreferences +} from './orchestration-worker-launch-preferences' +import type { WorkerStartInput } from './orchestration-worker-start-schema' + +type WorkerStartLaunch = ReturnType + +export function validateFederatedWorkerStartPlacement( + params: WorkerStartInput, + createsWorktree: boolean +): void { + if (createsWorktree && (!params.name || !params.repo)) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote new-top-level requires --name and an explicit --repo from remote discovery.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with remote new-worktree creation.' + ) + } + if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to remote new-top-level worktrees.' + ) + } + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) { + throw new OrchestrationError( + 'agent_unconfigured', + 'A configured --agent is required when remote worker-start creates a terminal.' + ) + } +} + +export function prepareLocalWorkerStart(args: { + params: WorkerStartInput + createsWorktree: boolean + runtime: OrcaRuntimeService +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + const { params, createsWorktree, runtime } = args + assertWorkerLaunchPreferencesCreateTerminal(params) + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with new-worktree creation.' + ) + } + if (createsWorktree && !params.name) { + throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') + } + if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to new-child or new-top-level worktrees.' + ) + } + return resolveWorkerStartAgent({ + runtime, + terminal: params.terminal, + agent: params.agent, + model: params.model, + effort: params.effort, + missingAgentMessage: 'A configured --agent is required when worker-start creates a terminal.' + }) +} + +export function prepareFederationAttachmentWorkerStart(args: { + params: FederationAttachStartInput + createsWorktree: boolean + runtime: OrcaRuntimeService +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + const { params, createsWorktree, runtime } = args + assertWorkerLaunchPreferencesCreateTerminal(params) + if (createsWorktree && (!params.name || !params.repo)) { + throw new OrchestrationError( + 'invalid_argument', + 'A remote new-top-level worktree requires --name and an explicit --repo.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with remote new-worktree creation.' + ) + } + if ( + !createsWorktree && + (params.name || params.repo || params.baseBranch || params.setup || params.setupSource) + ) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to remote new-top-level worktrees.' + ) + } + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + return resolveWorkerStartAgent({ + runtime, + terminal: params.terminal, + agent: params.agent, + model: params.model, + effort: params.effort, + missingAgentMessage: + 'A configured --agent is required when federated worker-start creates a terminal.' + }) +} + +function resolveWorkerStartAgent(args: { + runtime: OrcaRuntimeService + terminal?: string + agent?: string + model?: string + effort?: string + missingAgentMessage: string +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + if (!args.terminal && (!args.agent || !isTuiAgent(args.agent))) { + throw new OrchestrationError('agent_unconfigured', args.missingAgentMessage) + } + const agent = args.agent as TuiAgent | undefined + if (agent) { + args.runtime.validateOrchestrationAgentLauncher(agent) + return { + agent, + launch: resolveWorkerLaunchPreferences({ + agent, + model: args.model, + effort: args.effort + }) + } + } + return { + agent: undefined, + launch: { + preferences: undefined, + receipt: createWorkerLaunchReceipt({ agent: null }) + } + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 122a5b869ba..9672560ab1e 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -1,3 +1,4 @@ +import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' import type { TuiAgent } from '../../../../shared/types' import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' @@ -56,6 +57,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { runtime: OrcaRuntimeService worktreeId: string agent: TuiAgent + launchPreferences?: AgentLaunchPreferences taskId: string effects: WorkerEffect[] }): Promise<{ handle: string; warning?: string }> { @@ -64,6 +66,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { // desktop app while its CLI is `cursor-agent`. Let the runtime build the // configured launcher instead of executing the raw id. startupAgent: args.agent, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), title: `worker-${args.taskId}`, // Why: dispatching a worker is background work; it must not pull the sidebar // to the worker's workspace while the user is reading somewhere else. @@ -117,6 +120,7 @@ export async function createWorkerWorktree(args: { from: string } agent: TuiAgent + launchPreferences?: AgentLaunchPreferences effects: WorkerEffect[] }): Promise<{ worktree: Awaited> @@ -139,6 +143,7 @@ export async function createWorkerWorktree(args: { observeSetupCompletion: true, createdWithAgent: args.agent, startupAgent: args.agent, + ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}), activate: false, lineage: { parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined, diff --git a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts index 8e0a164178b..b83ab5b087b 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts @@ -157,6 +157,29 @@ describe('orchestration new-worktree workers', () => { expect(runtime.createTerminal).not.toHaveBeenCalled() }) + it('passes launch preferences into agent-first worktree creation', async () => { + mockCreatedWorktree() + + const { result } = await startWorker({ + model: 'custom-codex-model', + effort: 'high' + }) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + startupAgent: 'codex', + startupLaunchPreferences: { model: 'custom-codex-model', effort: 'high' } + }) + ) + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'codex', model: 'custom-codex-model', effort: 'high' }, + effective: { agent: 'codex', model: 'custom-codex-model', effort: 'high' } + } + }) + }) + it('rejects a new worktree for a folder project before creating effects', async () => { vi.mocked(runtime.showRepo).mockResolvedValue({ id: 'repo', diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index dc659773a8a..229253ec618 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -1,4 +1,3 @@ -import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -20,6 +19,7 @@ import { persistWorkerSetupWaitOutcome } from './orchestration-worker-setup-gate' import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt' +import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ defineMethod({ @@ -57,37 +57,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with new-worktree creation.' - ) - } - if (createsWorktree && !params.name) { - throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') - } - if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to new-child or new-top-level worktrees.' - ) - } - const agent = params.agent - if (!params.terminal && (!agent || !isTuiAgent(agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when worker-start creates a terminal.' - ) - } - if (agent) { - runtime.validateOrchestrationAgentLauncher(agent as TuiAgent) - } + const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) const coordinatorTerminal = await runtime.showTerminal(params.from) const coordinatorWorktree = await runtime.showManagedWorktree( @@ -130,6 +100,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ baseBranch: params.baseBranch ?? null, terminal: params.terminal ?? null, agent: agent ?? null, + launch: launch.receipt, timeoutMs: params.timeoutMs ?? 60_000, setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', setupSource: createsWorktree @@ -174,6 +145,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ coordinatorWorktree, params, agent: agent as TuiAgent, + launchPreferences: launch.preferences, effects }) resolvedWorktree = created.worktree @@ -190,6 +162,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ runtime, worktreeId: resolvedWorktree!.id, agent: agent as TuiAgent, + launchPreferences: launch.preferences, taskId: task.id, effects }) @@ -281,6 +254,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ state: worker.state, stage: worker.stage, setup: setupReceipt, + launch: launch.receipt, timeoutMs: params.timeoutMs ?? 60_000, effects, residualResources: [], @@ -294,7 +268,8 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ dispatchId: started.dispatch.id, failedStage, error, - setup: setupReceipt + setup: setupReceipt, + launch: launch.receipt }) } } diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 8ba154c48f7..d9d91237d08 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -298,6 +298,8 @@ describe('orchestration RPC methods', () => { describe('orchestration.send', () => { it('sends a message', async () => { setup() + // Why: send notifies arrival so already-idle recipients get push-on-idle + // delivery without waiting for a status transition (#12536). vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) const result = (await call('orchestration.send', { from: 'term_coord', @@ -308,7 +310,7 @@ describe('orchestration RPC methods', () => { expect(result.message.id).toMatch(/^msg_/) expect(result.message.from_handle).toBe('term_coord') expect(result.message.run_id).toBe(activeRunId) - expect(runtime.deliverPendingMessagesForHandle).not.toHaveBeenCalled() + expect(runtime.deliverPendingMessagesForHandle).toHaveBeenCalled() }) it('routes exact Dispatch mail independently of terminal handles', async () => { @@ -2235,6 +2237,61 @@ describe('orchestration RPC methods', () => { ) }) + it('applies and reports opaque per-invocation model preferences', async () => { + setup() + mockCurrentWorkerStart() + const task = db.createTask({ spec: 'launch a custom model' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + })) as { + dispatchId: string + state: string + launch: { + requested: { agent: string; model: string; effort: string } + effective: { agent: string; model: string; effort: string } + } + } + + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }, + effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' } + } + }) + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ + startupAgent: 'claude', + launchPreferences: { model: 'aws-bedrock-opus-5', effort: 'high' } + }) + ) + expect(JSON.parse(db.getWorkerDispatch(result.dispatchId)!.start_options)).toMatchObject({ + launch: result.launch + }) + }) + + it('rejects launch preferences for an existing terminal before creating a Dispatch', async () => { + setup() + mockCurrentWorkerStart() + const task = db.createTask({ spec: 'reuse exact worker' }) + + await expect( + call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + terminal: 'term_worker', + model: 'gpt-5.6-sol' + }) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) + // Why: `cursor` on PATH is the Cursor desktop app; passing the agent id as a // shell command opened the IDE and left a blank shell (issue #11926). it('never passes the agent id to the worker terminal as a shell command', async () => { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts new file mode 100644 index 00000000000..45f6613e354 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' + +function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapshot { + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + title: 'Claude', + parentTabId: 'tab-1', + leafId: 'leaf-1', + isActive: true, + agentStatus: { + state: 'done', + prompt: '', + updatedAt: 100, + stateStartedAt: 100, + paneKey: 'tab-1:leaf-1', + stateHistory: [], + sessionBoundary + } + } + ] + } +} + +describe('projectSessionTabAgentStatus', () => { + it('withholds session boundaries from legacy paired clients', () => { + const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', []) + + expect(projected.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('publishes session boundaries to clients that negotiated them', () => { + const snapshot = makeSnapshot(true) + + expect( + projectSessionTabAgentStatus(snapshot, 'runtime', [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY]) + ).toBe(snapshot) + }) + + it('does not alter local, mobile, or real-completion projections', () => { + const localBoundary = makeSnapshot(true) + const mobileBoundary = makeSnapshot(true) + const runtimeCompletion = makeSnapshot(false) + + expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined)).toBe(localBoundary) + expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [])).toBe(mobileBoundary) + expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [])).toBe(runtimeCompletion) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts new file mode 100644 index 00000000000..125e083db4f --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -0,0 +1,33 @@ +import type { RuntimeCapability } from '../../../../shared/protocol-version' +import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../../../shared/runtime-types' + +type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot + +export function projectSessionTabAgentStatus( + payload: TPayload, + clientKind: 'mobile' | 'runtime' | undefined, + clientCapabilities: readonly RuntimeCapability[] | undefined +): TPayload { + // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. + if ( + clientKind !== 'runtime' || + clientCapabilities?.includes(AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY) + ) { + return payload + } + + let changed = false + const tabs = payload.tabs.map((tab) => { + if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) { + return tab + } + changed = true + const { agentStatus: _boundary, ...legacyTab } = tab + return legacyTab + }) + return changed ? ({ ...payload, tabs } as TPayload) : payload +} diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 14716e0bf17..397753d609a 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -12,19 +12,26 @@ import { WorktreeTabSelector } from './session-tabs-schemas' import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime, pairedDeviceId }) => - runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) + handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => + projectSessionTabAgentStatus( + await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), + clientKind, + clientCapabilities + ) }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, { runtime, pairedDeviceId }) => ({ - snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) + handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({ + snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) => + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + ) }) }), defineMethod({ @@ -128,7 +135,11 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribe', params: WorktreeTabSelector, - handler: async (params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { + handler: async ( + params, + { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, + emit + ) => { let subscribedWorktree: string | null = null let unsubscribe = (): void => {} let closed = false @@ -156,7 +167,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (closed) { return } - emit({ type: 'snapshot', ...initial }) + emit({ + type: 'snapshot', + ...projectSessionTabAgentStatus(initial, clientKind, clientCapabilities) + }) initialized = true if (closed) { return @@ -164,7 +178,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { if (snapshot.worktree === subscribedWorktree) { - emit({ type: 'updated', ...snapshot }) + emit({ + type: 'updated', + ...projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + }) } }, pairedDeviceId) if (closed) { @@ -193,7 +210,11 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { + handler: async ( + _params, + { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, + emit + ) => { let unsubscribe = (): void => {} let closed = false // Why: initial listAll errors should return one RPC error, not a leaked @@ -227,14 +248,22 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (closed) { return } - emit({ type: 'snapshots', snapshots }) + emit({ + type: 'snapshots', + snapshots: snapshots.map((snapshot) => + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + ) + }) initialized = true if (closed) { return } unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emit({ type: 'updated', ...snapshot }) + emit({ + type: 'updated', + ...projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + }) }, pairedDeviceId) } }), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 05f94f2e631..1cdec021e55 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2623,7 +2623,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) const layoutSeq = runtime.getLayout(ptyId)?.seq - const snapshotFrameSeq = serialized?.seq ?? layoutSeq + // Why: layout versions and output offsets are different sequence domains. const snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', @@ -2664,7 +2664,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? 24, displayMode, - seq: snapshotFrameSeq, + seq: snapshotOutputSeq, cwd: serialized?.cwd, truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, @@ -3424,8 +3424,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const displayMode = runtime.getMobileDisplayMode(ptyId) // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. const layoutSeq = runtime.getLayout(ptyId)?.seq - const snapshotFrameSeq = serialized?.seq ?? layoutSeq - // Why: track the seq that actually covered the buffered chunks (recovery snapshots advance it) or an absorbed query gets zero replies. + // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. let snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', @@ -3445,7 +3444,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, displayMode, - seq: snapshotFrameSeq, + seq: snapshotOutputSeq, cwd: serialized?.cwd, truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts new file mode 100644 index 00000000000..665d94b0f41 --- /dev/null +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' + +afterEach(() => { + vi.useRealTimers() +}) + +const INTERVAL_MS = 100 + +function makeSocket(): WebSocket { + return { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket +} + +describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { + it('keeps a client whose pong is merely delayed past one probe window', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) // probe #1 + heartbeat.noteAlive(socket) // pongs probe #1 + + // A transient blackhole: probe #2 goes out and its pong is stuck in the network. + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(socket.ping).toHaveBeenCalledTimes(2) + expect(socket.terminate).not.toHaveBeenCalled() + + // The path recovers and the delayed pong lands right after the next sweep would have run. + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + heartbeat.noteAlive(socket) + + // One unanswered probe is not evidence the peer is gone: it must still be connected, + // and it must have been re-probed rather than reaped. + expect(socket.terminate).not.toHaveBeenCalled() + expect(socket.ping).toHaveBeenCalledTimes(3) + heartbeat.stop() + }) + + it('reaps only after consecutive probes go unanswered, counted not timed', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const missesBeforeReap: number[] = [] + for (let probe = 0; probe < 6; probe += 1) { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + missesBeforeReap.push( + (socket.terminate as unknown as ReturnType).mock.calls.length + ) + } + + // Silence must be tolerated for more than a single probe, and must eventually end the socket. + expect(missesBeforeReap[0]).toBe(0) + expect(missesBeforeReap[1]).toBe(0) + expect(missesBeforeReap.at(-1)).toBeGreaterThan(0) + heartbeat.stop() + }) + + it('lets any proof of life reset accumulated misses', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + // Two silent probes, then a single inbound frame, repeated well past any fixed budget. + for (let cycle = 0; cycle < 5; cycle += 1) { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + heartbeat.noteAlive(socket) + } + + expect(socket.terminate).not.toHaveBeenCalled() + heartbeat.stop() + }) + + it('charges no miss for a paused tick but keeps the ones already banked', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const sweep = async (): Promise => { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + const reaped = (candidate: WebSocket): boolean => + (candidate.terminate as unknown as ReturnType).mock.calls.length > 0 + + // Discover the budget rather than hardcoding it: the first sweep consumes the pong above, so + // every sweep after it is an unanswered probe. + await sweep() + let toleratedMisses = 0 + // Bounded so a heartbeat that never reaps fails here instead of hanging out to the test timeout. + while (!reaped(socket) && toleratedMisses < 20) { + await sweep() + toleratedMisses += 1 + } + expect(reaped(socket)).toBe(true) + expect(toleratedMisses).toBeGreaterThan(1) + heartbeat.stop() + + // Replay on a fresh socket: bank misses to one short of the limit, then suspend. The stalled tick + // must charge nothing — the client had no chance to answer it — but it must not forgive the misses + // already banked, or a host that stalls periodically could shelter a dead socket indefinitely. + const resumed = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + const resumedSocket = makeSocket() + resumed.noteAlive(resumedSocket) + resumed.start(() => [resumedSocket]) + resumed.noteAlive(resumedSocket) + await sweep() + for (let miss = 0; miss < toleratedMisses - 1; miss += 1) { + await sweep() + } + expect(reaped(resumedSocket)).toBe(false) + + now += 3_600_000 + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + // The suspend itself costs the socket nothing. + expect(reaped(resumedSocket)).toBe(false) + + // But the pre-suspend evidence survives, so the very next unanswered probe completes the budget. + await sweep() + expect(reaped(resumedSocket)).toBe(true) + resumed.stop() + }) + + // Why: pre-fix the pardon cleared banked misses, so a host stalling once every few ticks reset the + // budget forever and a dead socket was never reaped — the connection leak the reaper exists to stop. + it('still reaps a dead socket when the host stalls repeatedly', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const reaped = (): boolean => + (socket.terminate as unknown as ReturnType).mock.calls.length > 0 + + // Stall every third tick — the worst case the old pardon could not survive. + for (let tick = 0; tick < 30 && !reaped(); tick += 1) { + now += tick % 3 === 2 ? INTERVAL_MS * 2 : INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + + expect(reaped()).toBe(true) + heartbeat.stop() + }) +}) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts index a34b43c3c8b..e1a58d23587 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts @@ -7,12 +7,13 @@ afterEach(() => { }) describe('RemoteRuntimeServerHeartbeat', () => { - it('still reaps a client that misses a probe while another remains alive', async () => { + it('still reaps a persistently silent client while another remains alive', async () => { vi.useFakeTimers() let now = 1_000 const responsiveSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket const deadSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket - const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now) + // Limit of 2 keeps the sweep count readable; production uses the module default. + const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 2) heartbeat.noteAlive(responsiveSocket) heartbeat.noteAlive(deadSocket) // start() probes immediately: both are pinged now (probe #1) and cleared to await a pong. @@ -20,12 +21,20 @@ describe('RemoteRuntimeServerHeartbeat', () => { // Only the responsive socket pongs the immediate probe. heartbeat.noteAlive(responsiveSocket) + // Miss #1: not yet evidence, so the silent socket is re-probed rather than reaped. + now += 100 + await vi.advanceTimersByTimeAsync(100) + heartbeat.noteAlive(responsiveSocket) + expect(deadSocket.ping).toHaveBeenCalledTimes(2) + expect(deadSocket.terminate).not.toHaveBeenCalled() + + // Miss #2 reaches the limit: consecutive silence is evidence. now += 100 await vi.advanceTimersByTimeAsync(100) - expect(responsiveSocket.ping).toHaveBeenCalledTimes(2) + expect(responsiveSocket.ping).toHaveBeenCalledTimes(3) expect(responsiveSocket.terminate).not.toHaveBeenCalled() - expect(deadSocket.ping).toHaveBeenCalledTimes(1) + expect(deadSocket.ping).toHaveBeenCalledTimes(2) expect(deadSocket.terminate).toHaveBeenCalledTimes(1) heartbeat.stop() }) @@ -34,7 +43,8 @@ describe('RemoteRuntimeServerHeartbeat', () => { vi.useFakeTimers() let now = 1_000 const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket - const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now) + // Limit of 1 isolates the resume grant: without it the very next sweep would reap. + const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 1) heartbeat.noteAlive(socket) // start() probes immediately (ping #1); the socket pongs it. heartbeat.start(() => [socket]) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts index 3b5bb9760df..6134259c51f 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts @@ -1,16 +1,27 @@ import type { WebSocket } from 'ws' +// Why: one unanswered probe is UNKNOWN, not death — a cellular/Tailscale blackhole or a stalled TCP +// retransmit routinely swallows a single pong from a peer that is still there (STA-3320). Only a run of +// consecutive unanswered probes is evidence. Three matches the web client's own 45s liveness budget +// (25s idle + 20s probe grace), so both ends of a paired session give up on roughly the same evidence. +const MISSED_PROBE_LIMIT = 3 + export class RemoteRuntimeServerHeartbeat { private timer: ReturnType | null = null private lastTickAt: number | null = null private readonly alive = new WeakSet() + // Why: consecutive probes a socket has left unanswered. Absent = zero, so live sockets cost nothing. + private readonly missedProbes = new WeakMap() constructor( private readonly intervalMs: number, private readonly now: () => number = Date.now, - private readonly warningClientCount = 128 + private readonly warningClientCount = 128, + private readonly missedProbeLimit = MISSED_PROBE_LIMIT ) {} + // Why: hot path — every inbound frame calls this. The sweep clears the miss count when it observes + // the flag, so proof of life stays a single WeakSet write. noteAlive(socket: WebSocket): void { this.alive.add(socket) } @@ -41,22 +52,32 @@ export class RemoteRuntimeServerHeartbeat { const tickAt = this.now() const elapsedMs = tickAt - (this.lastTickAt ?? tickAt) this.lastTickAt = tickAt - const resumedFromPause = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5 + // Why: a delayed tick cannot infer that clients missed a probe they had no chance to answer, so + // this sweep must not charge one. It must not *forgive* either: clearing banked misses lets a host + // that stalls once every few ticks protect a dead socket forever, which is the connection leak the + // reaper exists to prevent. Skipping the charge is enough — a live client clears its own count by + // answering the probe still sent below. + const stalledTick = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5 let reaped = 0 let clientCount = 0 for (const socket of clients) { clientCount += 1 - if (resumedFromPause) { - // Why: a delayed server tick cannot infer that clients missed a probe they had no chance to answer. - this.alive.add(socket) + if (this.alive.has(socket)) { + this.alive.delete(socket) + this.missedProbes.delete(socket) + } else if (!stalledTick) { + const missed = (this.missedProbes.get(socket) ?? 0) + 1 + if (missed >= this.missedProbeLimit) { + this.missedProbes.delete(socket) + socket.terminate() + reaped += 1 + continue + } + this.missedProbes.set(socket, missed) } - if (!this.alive.has(socket)) { - socket.terminate() - reaped += 1 - continue - } - this.alive.delete(socket) try { + // Why: re-probe every sweep, including missed ones, so a path that just recovered can prove + // itself on the next tick instead of waiting out the rest of the budget. socket.ping() } catch { // Why: a mid-teardown socket is finalized by its close/error listener. diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index dd46b36650e..95ad62ec18c 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -444,6 +444,58 @@ function startSourceRangeOverflowHarness(options: { } describe('terminal multiplex RPC', () => { + it('keeps layout versions out of the output sequence domain', async () => { + let dataListener: ((data: string, meta?: RuntimeTerminalDataMeta) => void) | undefined + const harness = startDesktopMultiplexSubscribe({ + getLayout: vi.fn().mockReturnValue({ seq: 675 }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'restored terminal', + cols: 120, + rows: 40, + source: 'renderer' + }), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }) + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => + expect( + harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed') + ).toBe(true) + ) + + const subscribed = harness.messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed') + const snapshotStart = harness.binaryFrames + .map(decodeTerminalStreamFrame) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) + expect(subscribed.seq).toBe(675) + if (!snapshotStart) { + throw new Error('Missing multiplex snapshot start frame') + } + const snapshotPayload = decodeTerminalStreamJson(snapshotStart.payload) + expect(snapshotPayload).toMatchObject({ kind: 'scrollback' }) + expect(snapshotPayload).not.toHaveProperty('seq') + + harness.binaryFrames.splice(0) + dataListener?.('live', { seq: 4, rawLength: 4 }) + await vi.waitFor(() => + expect( + harness.binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return frame?.opcode === TerminalStreamOpcode.Output && frame.seq === 4 + }) + ).toBe(true) + ) + + harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.() + await harness.dispatchPromise + }) + it.each(['headless', 'renderer'] as const)( 'commits a source-range replacement only after the %s snapshot publishes', async (source) => { diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 837e49eff5a..8265e713fb3 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -833,15 +833,13 @@ describe('terminal subscribe buffering', () => { const decodedFrames = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) .filter((frame): frame is NonNullable => frame !== null) - const snapshotStarts = decodedFrames.filter( + const snapshotStart = decodedFrames.find( (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart ) - // Why seq 1 (layout seq): a no-output-seq snapshot falls back to the - // layout seq on the wire; the recovered data still ships as the first - // and only scrollback snapshot. - expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ - expect.objectContaining({ kind: 'scrollback', seq: 1 }) - ]) + // Why: layout versions and output offsets are different sequence domains. + const snapshotInfo = decodeTerminalStreamJson(snapshotStart!.payload) + expect(snapshotInfo).toMatchObject({ kind: 'scrollback' }) + expect(snapshotInfo).not.toHaveProperty('seq') const snapshotText = decodedFrames .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) .map((frame) => decodeTerminalStreamText(frame.payload)) diff --git a/src/main/runtime/rpc/ws-transport-accept-order.test.ts b/src/main/runtime/rpc/ws-transport-accept-order.test.ts index 4f56a2da53e..20ae28f0f0e 100644 --- a/src/main/runtime/rpc/ws-transport-accept-order.test.ts +++ b/src/main/runtime/rpc/ws-transport-accept-order.test.ts @@ -83,7 +83,7 @@ describe('WebSocketTransport accepted socket ordering', () => { ).toEqual([0, 0, 0, 0]) }) - it('reaps an unresponsive accepted socket by the first interval deadline', async () => { + it('reaps an unresponsive accepted socket once consecutive probes go unanswered', async () => { vi.useFakeTimers() const events: string[] = [] let socket: WebSocket @@ -99,19 +99,22 @@ describe('WebSocketTransport accepted socket ordering', () => { socket.once('open', () => events.push('open')) socket.emit('open') lifecycle.handleConnection(socket) - await vi.advanceTimersByTimeAsync(99) + // A single silent interval is not evidence: the socket is re-probed, not reaped. + await vi.advanceTimersByTimeAsync(100) + expect(socket.terminate).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(199) expect(socket.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) expect(socket.terminate).toHaveBeenCalledTimes(1) - expect(events).toEqual(['open', 'ping', 'close']) + expect(events).toEqual(['open', 'ping', 'ping', 'ping', 'close']) expect(lifecycle.heartbeatConnections.size).toBe(0) expect(lifecycle.heartbeat.timer).toBeNull() expect(vi.getTimerCount()).toBe(0) }) - it('keeps later sockets on the shared cadence and reaps them within two intervals', async () => { + it('keeps later sockets on the shared cadence and reaps them on the shared budget', async () => { vi.useFakeTimers() vi.setSystemTime(0) const firstPingTimes: number[] = [] @@ -150,13 +153,15 @@ describe('WebSocketTransport accepted socket ordering', () => { expect(laterPingTimes).toEqual([100]) expect(laterSocket.terminate).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(99) + // Re-probed on every sweep while its misses bank, so a recovered path can answer immediately. + await vi.advanceTimersByTimeAsync(299) + expect(laterPingTimes).toEqual([100, 200, 300]) expect(laterSocket.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) expect(laterSocket.terminate).toHaveBeenCalledTimes(1) - expect(laterReapTimes).toEqual([200]) - expect(firstPingTimes).toEqual([0, 100, 200]) + expect(laterReapTimes).toEqual([400]) + expect(firstPingTimes).toEqual([0, 100, 200, 300, 400]) expect( ['pong', 'message', 'close', 'error'].map((event) => laterSocket.listenerCount(event)) ).toEqual([0, 0, 0, 0]) diff --git a/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts b/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts new file mode 100644 index 00000000000..a8a5eb9c4de --- /dev/null +++ b/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts @@ -0,0 +1,83 @@ +// Real ws server + real ws client over loopback: a live, well-behaved peer whose pong is swallowed by +// a transient blackhole must not be terminated (STA-3320). +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { WebSocketTransport } from './ws-transport' + +const PROBE_INTERVAL_MS = 150 + +// Why: the heartbeat calls now() exactly once per sweep, so a per-call step makes elapsed exactly one +// interval every sweep — real scheduler jitter can never be mistaken for a suspended loop, and no +// assertion in this file depends on wall time. +function createStepClock(stepMs: number): () => number { + let calls = 0 + return () => 1_000_000 + calls++ * stepMs +} + +describe('WebSocketTransport under transient packet loss', () => { + const transports: WebSocketTransport[] = [] + const sockets: WebSocket[] = [] + + afterEach(async () => { + for (const socket of sockets) { + socket.removeAllListeners() + socket.terminate() + } + sockets.length = 0 + await Promise.all(transports.map((t) => t.stop().catch(() => {}))) + transports.length = 0 + }) + + async function startTransport(): Promise { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: 0, + heartbeatIntervalMs: PROBE_INTERVAL_MS, + heartbeatNow: createStepClock(PROBE_INTERVAL_MS), + // Why: this test is about liveness, not the auth handshake; keep the pre-auth reaper out of it. + preAuthTimeoutMs: 600_000 + }) + transports.push(transport) + await transport.start() + return transport + } + + it('keeps a live client whose pong is swallowed for a single probe', async () => { + const transport = await startTransport() + // autoPong:false hands us the protocol-level pong so we can drop exactly one, the way a + // cellular/Tailscale blackhole drops exactly the frames in flight during the stall. + const client = new WebSocket(`ws://127.0.0.1:${transport.resolvedPort}`, { autoPong: false }) + sockets.push(client) + + let probesReceived = 0 + const pongedProbes: number[] = [] + const swallowedProbe = 2 + let closed = false + client.on('ping', () => { + probesReceived += 1 + if (probesReceived === swallowedProbe) { + return + } + pongedProbes.push(probesReceived) + client.pong() + }) + client.on('close', () => { + closed = true + }) + await new Promise((resolve, reject) => { + client.once('open', resolve) + client.once('error', reject) + }) + + // Bounded by counted probe events, never by elapsed time. + await vi.waitFor(() => expect(closed || probesReceived >= swallowedProbe + 2).toBe(true), { + timeout: 15_000, + interval: 10 + }) + + expect({ closed, probesReceived }).toEqual({ closed: false, probesReceived: 4 }) + // The peer was demonstrably alive across the blackhole: it answered the probes on both sides of it. + expect(pongedProbes).toEqual([1, 3, 4]) + expect(client.readyState).toBe(WebSocket.OPEN) + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 1444813fd65..ec58f8697ae 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -21,7 +21,7 @@ type WebSocketMessageHandler = { ): void }['bivarianceHack'] -// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~30s (clients auto-pong per RFC 6455). +// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~60s (clients auto-pong per RFC 6455), since a reap needs consecutive unanswered probes rather than one (STA-3320). const HEARTBEAT_INTERVAL_MS = 15_000 export type WebSocketTransportOptions = { diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index 01aed591d79..41e272ecd29 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -243,7 +243,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { return { rows, markAsDelivered, - insert(subject: string): void { + insert(subject: string, type: StoredMessageRow['type'] = 'status'): void { rows.push({ id: `msg_${rows.length + 1}`, run_id: 'run_test', @@ -251,7 +251,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { to_handle: toHandle(), subject, body: '', - type: 'status', + type, priority: 'normal', thread_id: null, payload: null, @@ -263,9 +263,11 @@ function makeOrchestrationDbStub(toHandle: () => string) { }) }, db: { + // Mirrors the real query: `read = 0 AND delivered_at IS NULL`. getUndeliveredUnreadMessages: (handle: string) => - rows.filter((row) => row.to_handle === handle && !row.delivered_at), + rows.filter((row) => row.to_handle === handle && row.read === 0 && !row.delivered_at), getActiveCoordinatorRun: () => null, + getCurrentRunForPane: () => undefined, // Consulted by onPtyExit's dispatch-failure path. getActiveDispatchForTerminal: () => null, markAsDelivered, @@ -289,6 +291,137 @@ describe('push-on-idle orchestration delivery absence gate', () => { return { runtime, handle, write, stub } } + // Why: the gate that authorizes a push runs BEFORE the probe defers, so a + // same-id cold restore inside the probe window would otherwise be written to on + // the dead process's authority — ptyId is exactly what a same-id respawn keeps. + it('re-applies the live-idle gate when the probe answers after a same-id respawn', async () => { + let resolveProbe!: (value: boolean | null) => void + const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({ + probePtyLiveness: () => + new Promise((resolve) => { + resolveProbe = resolve + }) + }) + stub.insert('for the old session') + + runtime.notifyMessageArrived(handle, 'status') + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + + // The session dies and cold-restores under the same id while the probe is out. + runtime.onPtyExit(STALE_PTY_ID, 0) + runtime.onPtySpawned(STALE_PTY_ID, undefined, { awaitsRegistration: false }) + + resolveProbe(null) + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(write).not.toHaveBeenCalled() + expect(stub.rows[0].delivered_at).toBeNull() + + // The replacement's own live idle frame releases the row — through a fresh + // probe, since this leaf's pty is still unknown to the provider. + runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex done\x07', 200) + resolveProbe(null) + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(write).toHaveBeenCalledWith( + STALE_PTY_ID, + expect.stringContaining('You have 1 orchestration message') + ) + }) + + // Why: a `remote:` pty answers probePtyLiveness with null before its first + // await (ipc/pty.ts), so the probe settles on a pure microtask chain. Without a + // macrotask hop the continuation runs BEFORE the resumption of a check resolved + // in the meantime — the waiter is already out of the map and its rows are not + // yet read, so the push injects exactly what that check is about to return. + it('waits a macrotask before delivering so a resolved check consumes its rows first', async () => { + const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({ + probePtyLiveness: async () => null + }) + + const pulled: string[] = [] + const checkResumed = runtime + .waitForMessage(handle, { typeFilter: ['worker_done'], timeoutMs: 60_000 }) + .then(() => { + for (const row of stub.rows) { + if (row.type === 'worker_done' && row.read === 0) { + row.read = 1 + pulled.push(row.subject) + } + } + }) + + stub.insert('unclaimed status') + runtime.notifyMessageArrived(handle, 'status') + // Land the completion while the probe chain is mid-flight — the slot where + // the continuation would otherwise overtake the check's resumption. + await Promise.resolve() + await Promise.resolve() + stub.insert('worker completion', 'worker_done') + runtime.notifyMessageArrived(handle, 'worker_done') + + await checkResumed + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(pulled).toEqual(['worker completion']) + const payloads = write.mock.calls + .map(([, data]) => data) + .filter((data): data is string => typeof data === 'string') + const pointers = payloads.filter((data) => data.includes('orca orchestration check')) + expect(pointers).toHaveLength(1) + expect(pointers[0]).toContain('You have 1 orchestration message') + expect(payloads.some((data) => data.includes('unclaimed status'))).toBe(false) + expect(payloads.some((data) => data.includes('Subject: worker completion'))).toBe(false) + }) + + // Why: the notify-time reservation snapshot exists for a waiter resolved inside + // one microtask drain. The probe continuation runs many macrotasks later, and + // the probe dedup swallows every notify arriving meanwhile — so a reservation + // carried in here would skip a row with nothing left to retry it (#12536 again). + it('does not carry a stale waiter reservation into the probe continuation', async () => { + let resolveProbe!: (value: boolean | null) => void + const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({ + probePtyLiveness: () => + new Promise((resolve) => { + resolveProbe = resolve + }) + }) + + const waitPromise = runtime.waitForMessage(handle, { + typeFilter: ['worker_done'], + timeoutMs: 60_000 + }) + stub.insert('unclaimed status') + runtime.notifyMessageArrived(handle, 'status') + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + + // The reserving waiter goes away, then its type finally arrives — and the + // probe dedup drops this notify, so only the continuation can deliver it. + runtime.cancelMessageWaiters(handle) + await expect(waitPromise).resolves.toBe('cancelled') + stub.insert('late completion', 'worker_done') + runtime.notifyMessageArrived(handle, 'worker_done') + await Promise.resolve() + + resolveProbe(null) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Why twice: the probe continuation yields a turn before delivering. + await new Promise((resolve) => setTimeout(resolve, 0)) + + const payloads = write.mock.calls + .map(([, data]) => data) + .filter((data): data is string => typeof data === 'string') + const pointers = payloads.filter((data) => data.includes('orca orchestration check')) + expect(pointers).toHaveLength(1) + expect(pointers[0]).toContain('You have 2 orchestration messages') + expect(payloads.some((data) => data.includes('unclaimed status'))).toBe(false) + expect(payloads.some((data) => data.includes('late completion'))).toBe(false) + }) + it('keeps messages queued instead of marking a proven-absent pty delivered', async () => { const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({ probePtyLiveness: async () => false @@ -311,13 +444,17 @@ describe('push-on-idle orchestration delivery absence gate', () => { runtime.deliverPendingMessagesForHandle(handle) await new Promise((resolve) => setTimeout(resolve, 0)) + // Why twice: the probe continuation yields a turn before delivering. + await new Promise((resolve) => setTimeout(resolve, 0)) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, expect.stringContaining('Subject: hello')) + expect(write).toHaveBeenCalledWith( + STALE_PTY_ID, + expect.stringContaining('You have 1 orchestration message') + ) }) - // Why: delivered_at stamps only in the delayed-Enter callback, so the whole - // write→settle span — not just the probe — must be single-flight; a trigger - // landing inside the 500ms window would re-read the same un-stamped rows. + // Why: the whole pointer→Enter span must be single-flight; a trigger inside + // the 500ms window must park until the sequence watermark advances. it('delivers once across concurrent probe triggers and an in-window re-trigger, then flushes parked rows', async () => { vi.useFakeTimers() try { @@ -336,36 +473,33 @@ describe('push-on-idle orchestration delivery absence gate', () => { resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - const firstSubjectWrites = () => + const pointerWrites = () => write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: exactly once') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) + expect(pointerWrites()[0]?.[1]).toContain('You have 1 orchestration message') - // Re-trigger INSIDE the 500ms Enter window: the first batch is written but - // not yet stamped, so a fresh probe cycle would re-inject it. (On the - // fixed code no new probe is armed — the trigger parks; resolveProbe then - // re-resolves the settled first probe, a no-op.) + // Re-trigger inside the Enter window parks; resolving the settled first + // probe again is a no-op. stub.insert('second message') runtime.deliverPendingMessagesForHandle(handle) resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) - // Enter fires, delivered_at stamps, the flight settles, and the parked - // trigger re-runs on its own — arming a fresh probe for the new row. + // Enter settles the flight and re-runs the parked trigger, arming a fresh + // probe for the newer sequence. await vi.advanceTimersByTimeAsync(500) resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - const secondSubjectWrites = write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: second message') - ) - expect(secondSubjectWrites).toHaveLength(1) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(2) + expect(pointerWrites()[1]?.[1]).toContain('You have 2 orchestration messages') await vi.advanceTimersByTimeAsync(500) - expect(stub.rows.every((row) => row.delivered_at !== null)).toBe(true) + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true) } finally { vi.useRealTimers() } @@ -386,35 +520,30 @@ describe('push-on-idle orchestration delivery absence gate', () => { stub.insert('second') runtime.deliverPendingMessagesForHandle(handle) - const firstSubjectWrites = () => + const pointerWrites = () => write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: first') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) + expect(pointerWrites()[0]?.[1]).toContain('You have 1 orchestration message') expect(probe).not.toHaveBeenCalled() - // Settle flushes the parked trigger; the second row delivers alone — - // its batch must not re-contain the already-stamped first row. + // Settle flushes the parked trigger; both still-pending rows are counted, + // while the newer sequence authorizes exactly one fresh pointer. await vi.advanceTimersByTimeAsync(500) - const secondOnlyWrites = write.mock.calls.filter( - ([, data]) => - typeof data === 'string' && - data.includes('Subject: second') && - !data.includes('Subject: first') - ) - expect(secondOnlyWrites).toHaveLength(1) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(2) + expect(pointerWrites()[1]?.[1]).toContain('You have 2 orchestration messages') await vi.advanceTimersByTimeAsync(500) - expect(stub.rows.every((row) => row.delivered_at !== null)).toBe(true) + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true) } finally { vi.useRealTimers() } }) // Why: cold restore respawns under the SAME session id. An Enter armed for - // the dead incarnation must not fire into the replacement — it would inject - // \r and stamp rows the new session never received. + // the dead incarnation must not submit stale input into the replacement. it('retires an armed Enter when the pty exits and respawns under the same id inside the window', async () => { vi.useFakeTimers() try { @@ -435,15 +564,25 @@ describe('push-on-idle orchestration delivery absence gate', () => { expect(stub.markAsDelivered).not.toHaveBeenCalled() expect(stub.rows[0].delivered_at).toBeNull() - // The replacement's own delivery starts a fresh flight and completes. + // The replacement's own delivery starts a fresh flight and completes — + // but only once ITS live title proves idle; the dead session's live status + // no longer authorizes a write into the new process. runtime.deliverPendingMessagesForHandle(handle) + expect( + write.mock.calls.filter( + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') + ) + ).toHaveLength(1) + runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex working\x07', 200) + runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex done\x07', 201) const payloadWrites = write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: for the old session') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) expect(payloadWrites).toHaveLength(2) await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(1) - expect(stub.rows[0].delivered_at).not.toBeNull() + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows[0].delivered_at).toBeNull() } finally { vi.useRealTimers() } @@ -458,18 +597,21 @@ describe('push-on-idle orchestration delivery absence gate', () => { }) const internals = runtime as unknown as { messageDeliveryFlightsByPtyId: Map - parkedMessageRedeliveryLeavesByPtyId: Map + parkedMessageRedeliveriesByPtyId: Map + lastPointedMessageSequenceByHandle: Map } stub.insert('first') runtime.deliverPendingMessagesForHandle(handle) stub.insert('second') runtime.deliverPendingMessagesForHandle(handle) expect(internals.messageDeliveryFlightsByPtyId.size).toBe(1) - expect(internals.parkedMessageRedeliveryLeavesByPtyId.size).toBe(1) + expect(internals.parkedMessageRedeliveriesByPtyId.size).toBe(1) + expect(internals.lastPointedMessageSequenceByHandle.size).toBe(1) runtime.onPtyExit(STALE_PTY_ID, 0) expect(internals.messageDeliveryFlightsByPtyId.size).toBe(0) - expect(internals.parkedMessageRedeliveryLeavesByPtyId.size).toBe(0) + expect(internals.parkedMessageRedeliveriesByPtyId.size).toBe(0) + expect(internals.lastPointedMessageSequenceByHandle.size).toBe(0) await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) diff --git a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts index 7fd6c42b5fb..09c39f20137 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -37,6 +37,12 @@ type RuntimeInternals = { ) => unknown issuePtyHandle: (pty: unknown) => string headlessTerminals: Map + subscriberDrivenProviderAttachesByPtyId: Map> + subscriberDrivenProviderAttachInventoryWaiters: Set + refreshPtyWorktreeRecordsWithControllerInventory: ( + worktrees: [], + targetWorktreeId: string | null + ) => Promise } function internals(runtime: OrcaRuntimeService): RuntimeInternals { @@ -56,11 +62,21 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { const sessions = new Map() const attachCalls: string[] = [] const resizeCalls: [string, number, number][] = [] + let nextAttachBarrier: { promise: Promise; result: boolean } | null = null let runtime: OrcaRuntimeService | null = null const controller = { write: () => true, kill: () => true, getForegroundProcess: async () => null, + listProcesses: async () => + [...sessions.entries()].map(([id, session]) => ({ + id, + cwd: '/tmp/wt', + worktreeId: WORKTREE_ID, + title: '', + cols: session.cols, + rows: session.rows + })), hasRendererSerializer: () => false, getSize: (ptyId: string) => { const session = sessions.get(ptyId) @@ -72,6 +88,14 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { }, attach: async (ptyId: string) => { attachCalls.push(ptyId) + const barrier = nextAttachBarrier + nextAttachBarrier = null + if (barrier) { + await barrier.promise + if (!barrier.result) { + return false + } + } const session = sessions.get(ptyId) // Attach-only: an absent session is refused, never created. if (!session) { @@ -100,6 +124,14 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { sessions, attachCalls, resizeCalls, + deferNextAttach(result: boolean): () => void { + let release!: () => void + const promise = new Promise((resolve) => { + release = resolve + }) + nextAttachBarrier = { promise, result } + return release + }, bind(target: OrcaRuntimeService) { runtime = target }, @@ -376,6 +408,39 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { await vi.waitFor(() => expect(outputText(harness)).toContain('late daemon hello')) }) + it('retries an existing subscriber when provider inventory becomes ready', async () => { + const { runtime, model, handle } = setupNeverAttachedDaemonSession({ + snapshotCapable: false + }) + model.sessions.delete(PTY_ID) + const refuseFirstAttach = model.deferNextAttach(false) + const harness = startMultiplex(runtime) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + + sendSubscribe(harness, 1, handle) + await waitForSubscribed(harness, 1) + await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID])) + expect(internals(runtime).subscriberDrivenProviderAttachesByPtyId.has(PTY_ID)).toBe(true) + expect(runtime.hasRemoteTerminalViewSubscriber(PTY_ID)).toBe(true) + expect(model.emitData(PTY_ID, 'before inventory\r\n')).toBe(false) + + model.sessions.set(PTY_ID, { cols: 100, rows: 30, attached: false, screen: '' }) + await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) + await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) + expect(model.attachCalls).toEqual([PTY_ID]) + expect([...internals(runtime).subscriberDrivenProviderAttachInventoryWaiters]).toEqual([PTY_ID]) + refuseFirstAttach() + + await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID])) + await vi.waitFor(() => + expect(internals(runtime).subscriberDrivenProviderAttachInventoryWaiters.has(PTY_ID)).toBe( + false + ) + ) + expect(model.emitData(PTY_ID, 'after inventory\r\n')).toBe(true) + await vi.waitFor(() => expect(outputText(harness)).toContain('after inventory')) + }) + it('does not subscriber-attach or provider-read a session this app already spawned', async () => { const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: true, diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index 2b20055e646..f2cb31e1357 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -274,6 +274,139 @@ describe('read-only skill freshness inventory', () => { expect(inventory.eligibleUpdateNames).toEqual([]) }) + it('scopes the lock hash to the current bundle, not every path a revision ever shipped', async () => { + // A file revision 1 shipped and the current revision dropped is a stale leftover, not + // part of what the updater installed. Scoping to the union of all revisions would drag + // it back into the hash on the accident of its name, and the copy the CLI just wrote + // would read "may be modified". + const test = await fixture() + const legacy = describeObservedSkillFile( + 'references/legacy.md', + Buffer.from('dropped after rev 1\n'), + false + ) + const registryPath = join(test.resourceRoot, 'skills', 'snapshot-registry.json') + const registry = JSON.parse(await readFile(registryPath, 'utf8')) + registry.skills['orca-cli'][0].files.push(legacy) + await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) + + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + // The lock records the source tree — SKILL.md alone, no leftover. + const sourceTree = await test.writeSkill(join(test.root, 'source'), upstreamMarkdown) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(sourceTree)) + await mkdir(join(canonical, 'references'), { recursive: true }) + await writeFile(join(canonical, 'references', 'legacy.md'), 'dropped after rev 1\n') + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('trusts the updater lock for upstream bytes beside an agent CLI sidecar (#12694)', async () => { + // The reported folder shape after a successful update: `skills update` wrote + // source-repo HEAD no bundle knows yet, and Codex's own agents/openai.yaml sits + // beside it. The lock records the source tree — SKILL.md alone — so a folder hash + // taken over the sidecar too can never match it, and the copy the command just + // wrote would be reported as a failed update. + const test = await fixture() + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + const sourceTree = await test.writeSkill(join(test.root, 'source'), upstreamMarkdown) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(sourceTree)) + await mkdir(join(canonical, 'agents'), { recursive: true }) + await writeFile(join(canonical, 'agents', 'openai.yaml'), 'display_name: test\n') + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('still trusts the lock when the upstream revision added a file (#11220 guard)', async () => { + // The other half of the sidecar fix: scoping the lock hash to official paths alone + // would drop a file upstream genuinely shipped, and that file IS in the lock's tree. + // A clean install would then read "may be modified" and its update run report failure. + const test = await fixture() + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + await mkdir(join(canonical, 'references'), { recursive: true }) + await writeFile( + join(canonical, 'references', 'new.md'), + 'Shipped upstream, no bundle knows it\n' + ) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(canonical)) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('still withholds an unwinnable update when a sidecar sits beside the stale copy', async () => { + // Recognising the copy is only half the job: the lock already names the revision the + // source has, so `skills update` would compare lock to source, see no work and write + // nothing. Offering it promises a button that can never finish. A sidecar must not + // make the placement unidentifiable and retire that guard. + const test = await fixture() + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown + ) + await mkdir(join(canonical, 'agents'), { recursive: true }) + await writeFile(join(canonical, 'agents', 'openai.yaml'), 'display_name: test\n') + // The fixture's synthetic tree sha for revision 2 — the revision on disk is 1. + await writeSkillLockHash(test.homeDir, (2).toString(16).padStart(40, '0')) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1 + }) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + it('still flags canonical bytes that do not match what the lock says was installed', async () => { const test = await fixture() const editedMarkdown = `${test.currentMarkdown}\nLocal edit the updater never wrote.\n` @@ -522,10 +655,14 @@ describe('read-only skill freshness inventory', () => { expect(inventory.eligibleUpdateNames).toEqual([]) }) - it('keeps a plugin-cache copy with known official files unrecognized', async () => { + it('reads a plugin-cache copy with untouched official files as current', async () => { + // The deliberate posture change behind #12694: an unlisted neighbour is not evidence + // of an edit, so the bytes Orca owns decide alone — here and in every scope, not just + // the canonical copy the updater writes. The drifted-SKILL.md case above still fails + // closed, which is what keeps "unrecognized" meaningful. const test = await fixture() await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const modifiedRoot = join( + const withSidecarRoot = join( test.homeDir, '.codex', 'plugins', @@ -534,9 +671,9 @@ describe('read-only skill freshness inventory', () => { 'modified', 'orca-cli' ) - await mkdir(modifiedRoot, { recursive: true }) - await writeFile(join(modifiedRoot, 'SKILL.md'), test.currentMarkdown) - await writeFile(join(modifiedRoot, 'README.md'), 'Modified official package\n') + await mkdir(withSidecarRoot, { recursive: true }) + await writeFile(join(withSidecarRoot, 'SKILL.md'), test.currentMarkdown) + await writeFile(join(withSidecarRoot, 'README.md'), 'Neighbouring file Orca never shipped\n') const inventory = await inventorySkillFreshness({ currentAppVersion: '2.0.0', @@ -548,8 +685,8 @@ describe('read-only skill freshness inventory', () => { expect(inventory.installations).toEqual( expect.arrayContaining([ expect.objectContaining({ - unresolvedPath: modifiedRoot, - status: 'unrecognized' + unresolvedPath: withSidecarRoot, + status: 'current' }) ]) ) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index a3747451898..f502707321e 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -19,7 +19,7 @@ import { } from './skill-freshness-placement-observation' import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' import { convergableSkillNames } from './skill-update-convergence' -import { readGloballyUpdatableSkillLocks } from './skill-update-registration' +import { matchesUpdaterLock, readGloballyUpdatableSkillLocks } from './skill-update-registration' export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 @@ -35,8 +35,7 @@ function trustLockInstalledRevision( ): SkillFreshnessInstallation { return installation.status === 'unrecognized' && SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) && - installation.observedGitTreeSha != null && - installation.observedGitTreeSha === globalSkillLocks.get(installation.name) + matchesUpdaterLock(installation, globalSkillLocks.get(installation.name)) ? { ...installation, status: 'newer-known' } : installation } diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts index 87911de4823..02c9dcf80f5 100644 --- a/src/main/skills/skill-freshness-placement-observation.ts +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -8,7 +8,11 @@ import type { } from '../../shared/skill-freshness' import type { SkillScanRoot } from './skill-discovery-sources' import type { SkillBundleArtifacts } from './skill-bundle-artifacts' -import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity' +import { + matchingKnownSnapshot, + observeSkillPackage, + officialPathsGitTreeSha +} from './skill-package-identity' import { classifyHomeSkillTopology, classifyUnsupportedSkillTopology, @@ -95,10 +99,9 @@ export async function observeSkillFreshnessInstallation(args: { try { const observed = await observeSkillPackage(args.topology.resolvedPath) - const matchedSnapshot = matchingKnownSnapshot( - observed, - knownSnapshots(args.artifacts, args.current) - ) + const snapshots = knownSnapshots(args.artifacts, args.current) + const officialPaths = new Set(args.current.files.map((file) => file.path)) + const matchedSnapshot = matchingKnownSnapshot(observed, snapshots, officialPaths) // Why: a later release can reintroduce identical bytes. Exact current // identity is still current, and cannot honestly be attributed to the later tag. const snapshot = @@ -117,7 +120,8 @@ export async function observeSkillFreshnessInstallation(args: { null) : null, observedPackageDigest: observed.observedDigest, - observedGitTreeSha: observed.observedGitTreeSha + observedGitTreeSha: observed.observedGitTreeSha, + observedOfficialGitTreeSha: officialPathsGitTreeSha(observed, officialPaths) } } catch (error) { return { diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts index ca9beb9fd3d..7d362be9e7d 100644 --- a/src/main/skills/skill-package-identity.test.ts +++ b/src/main/skills/skill-package-identity.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -6,6 +6,7 @@ import { describeObservedSkillFile, matchingKnownSnapshot, observeSkillPackage, + officialPathsGitTreeSha, skillPackageDigest } from './skill-package-identity' @@ -17,6 +18,13 @@ async function temporarySkill(): Promise { return root } +/** A byte-identical folder elsewhere, so a hash can be derived without the original. */ +async function copyOf(source: string): Promise { + const target = await temporarySkill() + await cp(source, target, { recursive: true }) + return target +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) }) @@ -32,14 +40,18 @@ describe('skill package identity', () => { // hashes, not raw file buffers, should survive each file's identity pass. expect(observed.files[0]).not.toHaveProperty('bytes') expect( - matchingKnownSnapshot(observed, [ - { - releaseRevision: 1, - packageDigest: skillPackageDigest([expected]), - gitTreeSha: 'tree', - files: [expected] - } - ])?.releaseRevision + matchingKnownSnapshot( + observed, + [ + { + releaseRevision: 1, + packageDigest: skillPackageDigest([expected]), + gitTreeSha: 'tree', + files: [expected] + } + ], + new Set(['SKILL.md']) + )?.releaseRevision ).toBe(1) }) @@ -102,14 +114,18 @@ describe('skill package identity', () => { // has to come out clean too, not just the digest. expect(observed.observedGitTreeSha).toBe(official.observedGitTreeSha) expect( - matchingKnownSnapshot(observed, [ - { - releaseRevision: 1, - packageDigest: official.observedDigest, - gitTreeSha: official.observedGitTreeSha, - files: official.files - } - ])?.releaseRevision + matchingKnownSnapshot( + observed, + [ + { + releaseRevision: 1, + packageDigest: official.observedDigest, + gitTreeSha: official.observedGitTreeSha, + files: official.files + } + ], + new Set(['SKILL.md']) + )?.releaseRevision ).toBe(1) }) @@ -141,11 +157,13 @@ describe('skill package identity', () => { const edited = await temporarySkill() await writeFile(join(edited, 'SKILL.md'), 'skill\nlocal tweak\n') await writeFile(join(edited, '.DS_Store'), Buffer.from([0, 1])) - // An unexpected file that is NOT OS metadata must keep failing closed; tolerating - // extras in general would let an injected payload ride along beside a clean SKILL.md. + // Extra sidecar next to an untouched SKILL.md must still match — agent CLIs + // write agents/openai.yaml (and similar) without editing official bytes. + // Content drift on a listed file remains fail-closed. const withPayload = await temporarySkill() await writeFile(join(withPayload, 'SKILL.md'), 'skill\n') - await writeFile(join(withPayload, 'payload.sh'), '#!/bin/sh\n') + await mkdir(join(withPayload, 'agents'), { recursive: true }) + await writeFile(join(withPayload, 'agents', 'openai.yaml'), 'display_name: test\n') const snapshot = [ { @@ -155,8 +173,116 @@ describe('skill package identity', () => { files: official.files } ] - expect(matchingKnownSnapshot(await observeSkillPackage(edited), snapshot)).toBeNull() - expect(matchingKnownSnapshot(await observeSkillPackage(withPayload), snapshot)).toBeNull() + const official1 = new Set(['SKILL.md']) + expect(matchingKnownSnapshot(await observeSkillPackage(edited), snapshot, official1)).toBeNull() + expect( + matchingKnownSnapshot(await observeSkillPackage(withPayload), snapshot, official1) + ?.releaseRevision + ).toBe(1) + }) + + it('does not let an older revision launder drift on a file the current one lists', async () => { + // Subset matching treats a path the tested revision does not list as a neighbour. + // Left unguarded, revision 1 (SKILL.md alone) would match a revision-2 folder whose + // references/x.md had been rewritten — reporting a tampered package as merely + // outdated, with an update offered and no "may be modified" anywhere. + const pristine = await temporarySkill() + await writeFile(join(pristine, 'SKILL.md'), 'skill\n') + const revisionOne = await observeSkillPackage(pristine) + + const tampered = await temporarySkill() + await writeFile(join(tampered, 'SKILL.md'), 'skill\n') + await mkdir(join(tampered, 'references'), { recursive: true }) + await writeFile(join(tampered, 'references', 'x.md'), 'attacker content\n') + + const snapshots = [ + { + releaseRevision: 1, + packageDigest: revisionOne.observedDigest, + gitTreeSha: revisionOne.observedGitTreeSha, + files: revisionOne.files + } + ] + const observed = await observeSkillPackage(tampered) + + expect( + matchingKnownSnapshot(observed, snapshots, new Set(['SKILL.md', 'references/x.md'])) + ).toBeNull() + // The same folder is a plain sidecar case once the current bundle stops claiming + // that path, so the guard must key on what is official — not on file count. + expect(matchingKnownSnapshot(observed, snapshots, new Set(['SKILL.md']))?.releaseRevision).toBe( + 1 + ) + }) + + it('hashes official paths for the lock without hiding an upstream file it added', async () => { + const clean = await temporarySkill() + await writeFile(join(clean, 'SKILL.md'), 'skill\n') + const source = await observeSkillPackage(clean) + + const withSidecar = await temporarySkill() + await writeFile(join(withSidecar, 'SKILL.md'), 'skill\n') + await mkdir(join(withSidecar, 'agents'), { recursive: true }) + await writeFile(join(withSidecar, 'agents', 'openai.yaml'), 'display_name: test\n') + const sidecarObserved = await observeSkillPackage(withSidecar) + const officialPaths = new Set(['SKILL.md']) + + // A sidecar folder reaches the lock's source-tree hash only once scoped. + expect(sidecarObserved.observedGitTreeSha).not.toBe(source.observedGitTreeSha) + expect(officialPathsGitTreeSha(sidecarObserved, officialPaths)).toBe(source.observedGitTreeSha) + + // An upstream revision that ADDS a file puts it in the lock's own tree, so the + // whole-folder hash has to survive alongside — scoping it away would re-break the + // clean install this check exists to recognise (#11220). + const upstream = await temporarySkill() + await writeFile(join(upstream, 'SKILL.md'), 'skill\n') + await mkdir(join(upstream, 'references'), { recursive: true }) + await writeFile(join(upstream, 'references', 'new.md'), 'shipped upstream\n') + const upstreamObserved = await observeSkillPackage(upstream) + // The lock is the source tree the CLI installed, derived independently of the + // observation under test — comparing the observation to itself would assert nothing. + const upstreamLock = (await observeSkillPackage(await copyOf(upstream))).observedGitTreeSha + + expect(officialPathsGitTreeSha(upstreamObserved, officialPaths)).not.toBe(upstreamLock) + expect(upstreamObserved.observedGitTreeSha).toBe(upstreamLock) + }) + + it('scopes the lock hash to the current bundle, not every path ever shipped', async () => { + // A file an older revision shipped and the current one dropped is a stale leftover, + // not part of what the updater installed. Scoping to the union of all revisions would + // drag it back into the hash purely because its name was once official, and the copy + // would read "may be modified" over bytes the CLI itself wrote. + const source = await temporarySkill() + await writeFile(join(source, 'SKILL.md'), 'skill\n') + const lock = (await observeSkillPackage(source)).observedGitTreeSha + + const withLeftover = await temporarySkill() + await writeFile(join(withLeftover, 'SKILL.md'), 'skill\n') + await mkdir(join(withLeftover, 'references'), { recursive: true }) + await writeFile(join(withLeftover, 'references', 'legacy.md'), 'dropped after rev 1\n') + const observed = await observeSkillPackage(withLeftover) + + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).toBe(lock) + // The union of every revision's paths — what scoping must NOT use. + expect( + officialPathsGitTreeSha(observed, new Set(['SKILL.md', 'references/legacy.md'])) + ).not.toBe(lock) + }) + + it('does not hand the lock an empty tree when no official path is present', async () => { + // git's empty-tree sha is a real, matchable value; returning it would let a lock that + // ever recorded an empty source tree vouch for any folder holding nothing official. + const root = await temporarySkill() + await mkdir(join(root, 'agents'), { recursive: true }) + await writeFile(join(root, 'agents', 'openai.yaml'), 'display_name: test\n') + const observed = await observeSkillPackage(root) + + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).toBe( + observed.observedGitTreeSha + ) + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).not.toBe( + '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + ) }) it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => { diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index 4c883a2d6e6..5610479a4f7 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -14,8 +14,13 @@ type ObservedSkillFile = SkillBundleFileIdentity export type ObservedSkillPackage = { files: ObservedSkillFile[] observedDigest: string - /** Git tree sha of the raw bytes — comparable against the updater lock's skillFolderHash. */ + /** + * Git tree sha of every observed file's raw bytes — one of the two values comparable + * against the updater lock's `skillFolderHash` (see `officialPathsGitTreeSha`). + */ observedGitTreeSha: string + /** Retained so a subset of the observed paths can be re-hashed the same way. */ + treeEntries: SkillGitTreeFileEntry[] } // Why: package identity compares a live user directory against a tree the generator read @@ -250,21 +255,40 @@ export async function observeSkillPackage( return { files, observedDigest: skillPackageDigest(files), - observedGitTreeSha: skillPackageGitTreeSha(treeEntries) + observedGitTreeSha: skillPackageGitTreeSha(treeEntries), + treeEntries } } +/** + * The newest revision whose every listed file is present and byte-identical. + * + * `officialPaths` is what the CURRENT bundle says this skill owns, and it is what + * separates a tolerable neighbour from drift. Agent CLIs drop their own metadata + * beside an official SKILL.md (Codex writes `agents/openai.yaml` and cannot put it + * anywhere else), and a file no revision claims is not evidence the user edited + * anything — so extras alone must not mark the package unrecognized. + * + * A file the current bundle DOES list is never an extra, even when the revision + * being tested predates it: without that guard an older snapshot would happily match + * a folder whose newer official file had been tampered with, laundering real drift + * into a clean "outdated" row. Listed-file drift still fails closed either way. + */ export function matchingKnownSnapshot( observed: ObservedSkillPackage, - snapshots: readonly SkillKnownSnapshot[] + snapshots: readonly SkillKnownSnapshot[], + officialPaths: ReadonlySet ): SkillKnownSnapshot | null { + const observedByPath = new Map(observed.files.map((file) => [file.path, file])) for (const snapshot of snapshots.toReversed()) { - if (snapshot.files.length !== observed.files.length) { - continue - } + const listed = new Set(snapshot.files.map((file) => file.path)) + const launders = observed.files.some( + (file) => !listed.has(file.path) && officialPaths.has(file.path) + ) if ( - snapshot.files.every((expected, index) => { - const actual = observed.files[index] + !launders && + snapshot.files.every((expected) => { + const actual = observedByPath.get(expected.path) return Boolean(actual && matchesFileIdentity(actual, expected)) }) ) { @@ -273,3 +297,32 @@ export function matchingKnownSnapshot( } return null } + +/** + * The observed folder hashed as if it held only the files the current bundle owns. + * + * Compared against the updater lock's `skillFolderHash` ALONGSIDE the whole-folder + * hash, never instead of it. The two cover different halves and neither subsumes the + * other: the lock records the source tree, so a folder carrying a sidecar only ever + * matches scoped, while an upstream revision that ADDS a file puts that file in the + * lock's own tree and only ever matches whole. Publishing one and dropping the other + * would trade this bug for #11220 — a clean install reading "may be modified" and its + * update run reporting failure. + * + * Scoped to the CURRENT entry rather than every revision ever shipped: a leftover from + * an older revision is exactly the stale byte the lock comparison should look past, and + * unioning historical paths would drag it back in on the accident of its name. + * + * A folder holding none of them yields the whole-folder hash rather than git's empty-tree + * sha, which is a real, matchable value: a lock that ever recorded an empty source tree + * would otherwise vouch for every such folder. + */ +export function officialPathsGitTreeSha( + observed: ObservedSkillPackage, + officialPaths: ReadonlySet +): string { + const scoped = observed.treeEntries.filter((entry) => officialPaths.has(entry.path)) + return scoped.length === 0 || scoped.length === observed.treeEntries.length + ? observed.observedGitTreeSha + : skillPackageGitTreeSha(scoped) +} diff --git a/src/main/skills/skill-update-convergence.test.ts b/src/main/skills/skill-update-convergence.test.ts index 86e4f395e6d..3e8d2633a06 100644 --- a/src/main/skills/skill-update-convergence.test.ts +++ b/src/main/skills/skill-update-convergence.test.ts @@ -2,13 +2,22 @@ import { describe, expect, it } from 'vitest' import type { SkillFreshnessInstallation, SkillKnownSnapshot } from '../../shared/skill-freshness' import { convergableSkillNames } from './skill-update-convergence' +// `installedReleaseRevision` is the revision observation resolved this copy to, and +// null is the honest "readable, but matching nothing we know". A null +// `observedPackageDigest` is the different case of a copy that could not be read. +// +// The digest defaults to the resolved revision's own, so these fixtures describe a +// folder holding nothing but official files. That keeps the revert signal honest: +// swapping resolution back to a digest lookup still passes every case below except +// the sidecar one, which is the only case that distinguishes the two mechanisms. function placement( name: string, - observedPackageDigest: string | null, - topology: SkillFreshnessInstallation['topology'] = 'canonical-copy' + installedReleaseRevision: number | null, + topology: SkillFreshnessInstallation['topology'] = 'canonical-copy', + observedPackageDigest: string | null = `digest-${installedReleaseRevision}` ): SkillFreshnessInstallation { return { - id: `${name}:${observedPackageDigest}:${topology}`, + id: `${name}:${installedReleaseRevision}:${topology}`, name, rootId: 'home', providers: [], @@ -19,7 +28,7 @@ function placement( physicalIdentity: '1:1', topology, status: 'outdated', - installedReleaseRevision: null, + installedReleaseRevision, installedAppVersion: null, currentReleaseRevision: 8, currentPackageDigest: 'digest-current', @@ -29,24 +38,22 @@ function placement( } } -function revision(packageDigest: string, gitTreeSha: string): SkillKnownSnapshot { - return { releaseRevision: 1, packageDigest, gitTreeSha, files: [] } +function revision(releaseRevision: number, gitTreeSha: string): SkillKnownSnapshot { + return { releaseRevision, packageDigest: `digest-${releaseRevision}`, gitTreeSha, files: [] } } +const PRE_STUB = revision(1, 'f3727995') +const STUB = revision(2, '091d9bcc') + describe('convergableSkillNames', () => { // The real reported case: the lock records the stub tree (091d9bcc) while disk // still holds the pre-stub revision (f3727995). `skills update` compares lock to // source, sees no work, exits 0 and writes nothing — forever. it('drops a skill whose lock records a revision the disk does not have', () => { const result = convergableSkillNames( - [placement('orca-linear', 'digest-pre-stub')], + [placement('orca-linear', 1)], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) @@ -55,42 +62,50 @@ describe('convergableSkillNames', () => { // has simply moved ahead of what this build bundles. The update really can converge. it('keeps a skill whose lock matches disk even when it is outdated', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-installed')], + [placement('orca-cli', 1)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + { 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) }) it('keeps a skill whose disk content matches no known revision', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-unknown')], + [placement('orca-cli', null)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-other', 'bbbb2222')] } + { 'orca-cli': [revision(1, 'bbbb2222')] } ) expect([...result]).toEqual(['orca-cli']) }) it('keeps a skill with no observable placement', () => { const result = convergableSkillNames( - [placement('orca-cli', null)], + [placement('orca-cli', null, 'canonical-copy', null)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + { 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) }) + // Why: a sidecar an agent CLI dropped beside the official files makes the folder + // digest match no revision at all, so resolving disk content by that digest would + // read as unidentifiable and quietly re-arm the unwinnable update. Observation + // already placed this copy at the pre-stub revision; the gate must honour that. + it('drops a stale skill whose folder holds files no revision lists', () => { + const result = convergableSkillNames( + [placement('orca-linear', 1, 'canonical-copy', 'digest-with-sidecar')], + new Map([['orca-linear', '091d9bcc']]), + { 'orca-linear': [PRE_STUB, STUB] } + ) + expect([...result]).toEqual([]) + }) + // One placement still matching the lock means the command has an anchor to write. it('keeps a skill when any placement still matches the lock', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-installed'), placement('orca-cli', 'digest-pre-stub')], + [placement('orca-cli', 1), placement('orca-cli', 2)], new Map([['orca-cli', 'aaaa1111']]), - { - 'orca-cli': [ - revision('digest-installed', 'aaaa1111'), - revision('digest-pre-stub', 'f3727995') - ] - } + { 'orca-cli': [revision(1, 'aaaa1111'), revision(2, 'f3727995')] } ) expect([...result]).toEqual(['orca-cli']) }) @@ -98,23 +113,21 @@ describe('convergableSkillNames', () => { // A lock hash we cannot place is not evidence the command is stuck. it('keeps a skill whose lock names no revision we know', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-pre-stub')], + [placement('orca-cli', 1)], new Map([['orca-cli', 'not-a-known-tree']]), - { 'orca-cli': [revision('digest-pre-stub', 'f3727995')] } + { 'orca-cli': [revision(1, 'f3727995')] } ) expect([...result]).toEqual(['orca-cli']) }) - // Why: `diskTreeShas` silently drops digests that match no known revision, so a + // Why: `diskTreeShas` silently drops placements that resolved to nothing, so a // stale copy sitting beside an unidentifiable one must NOT gate the name — the // unknown half could be anything, including a copy the command would converge. it('keeps a skill when one placement is stale but another is unidentifiable', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-pre-stub'), placement('orca-cli', 'digest-unknown')], + [placement('orca-cli', 1), placement('orca-cli', null)], new Map([['orca-cli', '091d9bcc']]), - { - 'orca-cli': [revision('digest-pre-stub', 'f3727995'), revision('digest-stub', '091d9bcc')] - } + { 'orca-cli': [PRE_STUB, STUB] } ) expect([...result]).toEqual(['orca-cli']) }) @@ -124,17 +137,9 @@ describe('convergableSkillNames', () => { // placement and re-arm the unwinnable update on the drifted canonical. it('ignores an unidentifiable plugin-cache copy when judging the canonical', () => { const result = convergableSkillNames( - [ - placement('orca-linear', 'digest-pre-stub'), - placement('orca-linear', 'digest-cache-repack', 'plugin-cache') - ], + [placement('orca-linear', 1), placement('orca-linear', null, 'plugin-cache')], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) @@ -143,34 +148,23 @@ describe('convergableSkillNames', () => { // the command only writes the canonical, which is still drifted. it('ignores a plugin-cache copy that matches the lock', () => { const result = convergableSkillNames( - [ - placement('orca-linear', 'digest-pre-stub'), - placement('orca-linear', 'digest-stub', 'plugin-cache') - ], + [placement('orca-linear', 1), placement('orca-linear', 2, 'plugin-cache')], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) it('judges each locked skill independently', () => { const result = convergableSkillNames( - [placement('orca-linear', 'digest-pre-stub'), placement('orca-cli', 'digest-installed')], + [placement('orca-linear', 1), placement('orca-cli', 1)], new Map([ ['orca-linear', '091d9bcc'], ['orca-cli', 'aaaa1111'] ]), { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ], - 'orca-cli': [revision('digest-installed', 'aaaa1111')] + 'orca-linear': [PRE_STUB, STUB], + 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) diff --git a/src/main/skills/skill-update-convergence.ts b/src/main/skills/skill-update-convergence.ts index 41889058f3f..c318e89d8b8 100644 --- a/src/main/skills/skill-update-convergence.ts +++ b/src/main/skills/skill-update-convergence.ts @@ -34,20 +34,27 @@ export function convergableSkillNames( // it must neither gate the name nor rescue it — an unidentifiable cache copy // (or one parked at the lock's own revision) would otherwise defeat the gate // and re-arm the unwinnable update. - const digests = installations - .filter( - (entry) => - entry.name === name && - SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && - entry.observedPackageDigest - ) - .map((entry) => entry.observedPackageDigest) - if (digests.length === 0) { + const observable = installations.filter( + (entry) => + entry.name === name && + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && + entry.observedPackageDigest + ) + if (observable.length === 0) { continue } const revisions = knownSnapshots[name] ?? [] - const diskTreeShas = digests - .map((digest) => revisions.find((revision) => revision.packageDigest === digest)?.gitTreeSha) + // Why: the revision each placement resolved to during observation, not a fresh + // lookup by whole-folder digest. Identity tolerates files the manifest never + // listed, so a folder holding an agent CLI's sidecar digests to nothing any + // revision knows — re-deriving here would call every such placement + // unidentifiable and quietly retire the gate for the users who most need it. + const diskTreeShas = observable + .map( + (entry) => + revisions.find((revision) => revision.releaseRevision === entry.installedReleaseRevision) + ?.gitTreeSha + ) .filter((sha): sha is string => Boolean(sha)) // Why: only claim the lock is stale when BOTH sides are positively identified — // the lock names a revision we know, and every placement resolves to a different @@ -55,11 +62,11 @@ export function convergableSkillNames( // revision older than the registry) is not evidence of anything, and gating on it // would withhold updates that would have worked. const lockNamesAKnownRevision = revisions.some((entry) => entry.gitTreeSha === lockHash) - // `diskTreeShas` drops digests that match no known revision, so requiring every - // observed digest to resolve is what keeps `every` honest: without it, one stale - // copy beside one unidentifiable copy would gate the name off the resolved half - // alone, contradicting the unknown-stays-eligible rule above. - const everyPlacementResolved = diskTreeShas.length === digests.length + // `diskTreeShas` drops placements that resolved to no known revision, so requiring + // every observable one to resolve is what keeps `every` honest: without it, one + // stale copy beside one unidentifiable copy would gate the name off the resolved + // half alone, contradicting the unknown-stays-eligible rule above. + const everyPlacementResolved = diskTreeShas.length === observable.length if ( lockNamesAKnownRevision && everyPlacementResolved && diff --git a/src/main/skills/skill-update-outcome.ts b/src/main/skills/skill-update-outcome.ts index 9a15b856644..9c032cb9f2f 100644 --- a/src/main/skills/skill-update-outcome.ts +++ b/src/main/skills/skill-update-outcome.ts @@ -2,6 +2,7 @@ import { SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, type SkillFreshnessInstallation } from '../../shared/skill-freshness' +import { matchesUpdaterLock } from './skill-update-registration' /** * Names that did not land, judged from the post-run inventory. @@ -53,9 +54,5 @@ function skillPlacementLanded( // yet. Everything else unrecognized (half-written, no lock entry, mismatch) // still fails, and `outdated` is never forgiven: lock == disk there means the // command provably wrote nothing. - return ( - entry.status === 'unrecognized' && - lockHash !== undefined && - entry.observedGitTreeSha === lockHash - ) + return entry.status === 'unrecognized' && matchesUpdaterLock(entry, lockHash) } diff --git a/src/main/skills/skill-update-registration.ts b/src/main/skills/skill-update-registration.ts index 66231d71fbe..e80b8dc0580 100644 --- a/src/main/skills/skill-update-registration.ts +++ b/src/main/skills/skill-update-registration.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import type { SkillFreshnessInstallation } from '../../shared/skill-freshness' const GLOBAL_SKILL_LOCK_SCHEMA_VERSION = 3 @@ -21,6 +22,32 @@ function globalSkillLockPath(args: SkillUpdateRegistrationArgs): string { : join(args.homeDir ?? homedir(), '.agents', '.skill-lock.json') } +/** + * Whether a placement holds exactly the bytes the updater recorded installing. + * + * Either hash may carry it. The lock is the git tree sha of the source folder, so a + * folder an agent CLI dropped a sidecar into only matches once that sidecar is scoped + * out, while an upstream revision that added a file only matches whole — that file is + * in the lock's own tree but in no bundled snapshot. Requiring one specific hash trades + * either #12694 or #11220 for the other. + * + * Not exhaustive, and deliberately so: a folder carrying BOTH a sidecar and an upstream + * file no bundle lists matches neither hash and stays unrecognized, exactly as it does + * without this pair. Closing that needs the sidecar gone before hashing — an observe-time + * exclusion like `isOsMetadataSkillEntryName`, which means naming the foreign paths rather + * than tolerating any unlisted one. Editing a listed file still matches neither hash. + */ +export function matchesUpdaterLock( + installation: SkillFreshnessInstallation, + lockHash: string | undefined +): boolean { + return ( + lockHash !== undefined && + (installation.observedGitTreeSha === lockHash || + installation.observedOfficialGitTreeSha === lockHash) + ) +} + export async function readGloballyUpdatableSkillNames( args: SkillUpdateRegistrationArgs = {} ): Promise> { diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts new file mode 100644 index 00000000000..3a389bb80d4 --- /dev/null +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { getAppImageCliArgs } from './appimage-cli-redirect' +import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' + +// Why: index.ts must run both CLI redirects before rewriting argv. Rewriting +// first replaces the `serve` positional with `--serve`, so the redirect's +// command-name lookup finds a port number instead of a command and bails — +// silently dropping AppImage serve launches out of the CLI path (#12677). + +const REDIRECT_OPTIONS = { + platform: 'linux' as const, + isPackaged: true, + commandNames: ['serve', 'status'] +} +// A mounted AppImage is the case where the runtime does export these. +const MOUNTED_APPIMAGE_ENV = { APPIMAGE: '/opt/orca/Orca.AppImage', APPDIR: '/tmp/.mount_ab12' } + +function rewriteAsIndexDoes(argv: string[]): string[] { + return argvRequestsServeMode(argv) ? normalizeServeModeArgv(argv) : argv +} + +describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { + const launchArgv = ['/opt/orca/orca-ide', '--no-sandbox', 'serve', '--port', '7777', '--json'] + + it('hands the launch argv to the CLI when the redirect runs first', () => { + expect(getAppImageCliArgs(launchArgv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual([ + 'serve', + '--port', + '7777', + '--json' + ]) + }) + + it('loses the redirect if the rewrite runs first', () => { + const rewritten = rewriteAsIndexDoes(launchArgv) + expect(rewritten).toContain('--serve') + expect(getAppImageCliArgs(rewritten, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull() + }) + + it('leaves non-serve CLI commands redirectable either way', () => { + const argv = ['/opt/orca/orca-ide', 'status'] + expect(rewriteAsIndexDoes(argv)).toEqual(argv) + expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) + }) + + // Why source text: the ordering only exists as statement order at index.ts module scope, and the + // cases above stay green if it is reversed — nothing else would catch the regression. + it('keeps index.ts running both CLI redirects before the argv rewrite', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') + const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') + const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') + const serveModeCheck = source.indexOf("const isServeMode = process.argv.includes('--serve')") + + expect(packagedRedirect).toBeGreaterThanOrEqual(0) + expect(appImageRedirect).toBeGreaterThanOrEqual(0) + expect(rewrite).toBeGreaterThan(packagedRedirect) + expect(rewrite).toBeGreaterThan(appImageRedirect) + // The rewrite is pointless unless it lands before the flag it exists to inject is read. + expect(serveModeCheck).toBeGreaterThan(rewrite) + }) +}) diff --git a/src/main/startup/serve-mode-argv.test.ts b/src/main/startup/serve-mode-argv.test.ts new file mode 100644 index 00000000000..e82c02189d8 --- /dev/null +++ b/src/main/startup/serve-mode-argv.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest' +import { + argvRequestsServeMode, + findServeSubcommandIndex, + normalizeServeModeArgv +} from './serve-mode-argv' + +describe('serve-mode-argv', () => { + it('detects --serve and bare serve subcommand', () => { + expect(argvRequestsServeMode(['orca', '--serve'])).toBe(true) + expect(argvRequestsServeMode(['/AppRun', 'serve'])).toBe(true) + expect(argvRequestsServeMode(['/AppRun', '--no-sandbox', 'serve', '--port', '8080'])).toBe(true) + expect(argvRequestsServeMode(['orca'])).toBe(false) + }) + + it('does not treat serve as a subcommand when it is an option value', () => { + expect(findServeSubcommandIndex(['app', '--user-data-dir', 'serve'])).toBe(-1) + expect(argvRequestsServeMode(['app', '--user-data-dir', 'serve'])).toBe(false) + expect(normalizeServeModeArgv(['app', '--user-data-dir', 'serve'])).toEqual([ + 'app', + '--user-data-dir', + 'serve' + ]) + // The value is skipped, not the subcommand after it. + expect(findServeSubcommandIndex(['app', '--user-data-dir', '/tmp/x', 'serve'])).toBe(3) + }) + + it('refuses a help launch instead of binding a server', () => { + // Why: `--help` is not a serve flag, so it used to be swallowed and the launch bound a + // network-exposed runtime server with pairing on. The AppImage redirect routes help to the CLI. + for (const argv of [ + ['/AppRun', 'serve', '--help'], + ['/AppRun', 'serve', '-h'], + ['/AppRun', '--help', 'serve'], + ['/AppRun', 'help', 'serve'], + // Bare `help` too — the AppImage redirect's own help set includes it. + ['/AppRun', 'serve', 'help'] + ]) { + expect(argvRequestsServeMode(argv), argv.join(' ')).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + } + // A directory named `help` is a value, not a help request. + expect(argvRequestsServeMode(['/AppRun', 'serve', '--project-root', 'help'])).toBe(true) + // Past an operator's terminator nothing is reinterpreted, help flags included. + expect(argvRequestsServeMode(['/AppRun', 'serve', '--', '--help'])).toBe(true) + }) + + it('does not turn `--no-pairing=false` into no-pairing', () => { + // Why: the CLI reads serve booleans as `=== true`, so `=false` leaves pairing ON. Translating + // the token dropped the value and disabled pairing instead — the inversion of the bug this + // module exists to fix. + expect( + normalizeServeModeArgv(['/AppRun', 'serve', '--port', '6768', '--no-pairing=false']) + ).toEqual(['/AppRun', '--serve', '--serve-port', '6768', '--no-pairing=false']) + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--mobile-pairing=0'])).toEqual([ + '/AppRun', + '--serve', + '--mobile-pairing=0' + ]) + }) + + it('leaves real GUI launch argv alone', () => { + // Why: a false positive here is the expensive direction — the window never opens and a runtime + // server binds instead. These are the argv shapes the desktop actually receives. + for (const argv of [ + ['/Applications/Orca.app/Contents/MacOS/Orca', '-psn_0_123456'], + ['C:\\Program Files\\Orca\\Orca.exe', '--squirrel-firstrun'], + ['C:\\Program Files\\Orca\\Orca.exe', 'orca://worktree/serve'], + ['/opt/orca/orca-ide', '/home/u/serve'], + // `--pairing-code` takes the next token, so its value is never the subcommand. + ['/opt/orca/orca-ide', '--pairing-code', 'serve'], + ['/opt/orca/orca-ide', '--environment=serve'], + ['/opt/orca/orca-ide', '--', 'serve'], + ['/opt/orca/orca-ide', 'Serve'] + ]) { + expect(argvRequestsServeMode(argv), argv.join(' ')).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + } + }) + + it('does not treat a later positional serve as the subcommand after another command', () => { + expect(findServeSubcommandIndex(['app', 'status', 'serve'])).toBe(-1) + expect(argvRequestsServeMode(['app', 'status', 'serve'])).toBe(false) + }) + + it('rewrites CLI-form serve flags into --serve* form', () => { + expect( + normalizeServeModeArgv([ + '/AppRun', + 'serve', + '--port', + '9090', + '--json', + '--pairing-address', + '0.0.0.0', + '--no-pairing' + ]) + ).toEqual([ + '/AppRun', + '--serve', + '--serve-port', + '9090', + '--serve-json', + '--serve-pairing-address', + '0.0.0.0', + '--serve-no-pairing' + ]) + }) + + it('leaves already-normalized argv unchanged', () => { + // Why every value flag: the CLI's own `orca serve` spawns the app with exactly this shape + // (serveOrcaApp), and the rewrite now runs over it too — a bad mapping would drop the port here. + const argv = [ + 'orca', + '--serve', + '--serve-json', + '--serve-port', + '6768', + '--serve-pairing-address', + '100.64.1.20', + '--serve-no-pairing', + '--serve-mobile-pairing', + '--serve-recipe-json', + '--serve-project-root', + '/srv/repo' + ] + expect(normalizeServeModeArgv(argv)).toEqual(argv) + }) + + it('splits `--flag=value` CLI form, which getServeOptions cannot read', () => { + expect( + normalizeServeModeArgv(['/AppRun', 'serve', '--port=9090', '--pairing-address=0.0.0.0']) + ).toEqual(['/AppRun', '--serve', '--serve-port', '9090', '--serve-pairing-address', '0.0.0.0']) + }) + + it('translates serve flags in the mixed `--serve --port` form', () => { + // Why: leaving these untranslated silently kept pairing enabled despite --no-pairing. + expect(normalizeServeModeArgv(['orca', '--serve', '--port', '9090', '--no-pairing'])).toEqual([ + 'orca', + '--serve', + '--serve-port', + '9090', + '--serve-no-pairing' + ]) + }) + + it('leaves a non-serve launch untouched', () => { + const argv = ['orca', '--no-sandbox', '/home/u/project'] + expect(argvRequestsServeMode(argv)).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + }) + + it('does not splice prototype members onto argv for stray positionals', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', 'toString'])).toEqual([ + '/AppRun', + '--serve', + 'toString' + ]) + }) + + it('passes args after `--` through verbatim', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--json', '--', '--port', '1'])).toEqual([ + '/AppRun', + '--serve', + '--serve-json', + '--', + '--port', + '1' + ]) + }) + + it('never disagrees with itself about whether a launch is serve mode', () => { + // Why exhaustive: index.ts asks `argvRequestsServeMode` and then reads `--serve` out of the + // rewrite. If the two halves consume values differently one can swallow the `serve` token the + // other is rewriting, and the launch boots a desktop window with every serve flag dropped — + // #12677 again. Found by fuzzing `--port --port serve` and `--port -- serve`. + const alphabet = [ + 'serve', + '--serve', + '--port', + '--json', + '--pairing-code', + '--user-data-dir', + '--', + '-h', + 'help', + 'value', + '--port=1', + '--no-pairing=false' + ] + const disagreements: string[][] = [] + const walk = (tail: string[]): void => { + const argv = ['/AppRun', ...tail] + if (argvRequestsServeMode(argv) !== normalizeServeModeArgv(argv).includes('--serve')) { + disagreements.push(argv) + } + if (tail.length === 4) { + return + } + for (const token of alphabet) { + walk([...tail, token]) + } + } + walk([]) + + expect(disagreements).toEqual([]) + }) +}) diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts new file mode 100644 index 00000000000..3ba25fd8d85 --- /dev/null +++ b/src/main/startup/serve-mode-argv.ts @@ -0,0 +1,167 @@ +/** + * Detect and normalize headless-serve argv for both Electron flags (`--serve`) + * and CLI-form subcommands (`serve --port …`) that land on the main process + * when the AppImage/CLI redirect did not rewrite them. + */ + +const SERVE_FLAG = '--serve' + +// Why Map, not a record: `'toString' in {}` is true, so an object lookup turns a +// stray `serve toString` positional into a function spliced onto argv. +const CLI_TO_SERVE_FLAG = new Map([ + ['--json', '--serve-json'], + ['--no-pairing', '--serve-no-pairing'], + ['--mobile-pairing', '--serve-mobile-pairing'], + ['--recipe-json', '--serve-recipe-json'] +]) + +const CLI_TO_SERVE_VALUE_FLAG = new Map([ + ['--port', '--serve-port'], + ['--pairing-address', '--serve-pairing-address'], + ['--project-root', '--serve-project-root'] +]) + +/** + * Flags that consume the next argv token as a value (CLI-form + Electron passthrough). + * Residual class: a flag outside this list whose space-separated value is literally `serve` would + * read as the subcommand. Chromium switches are `--flag=value` only, so no real launch does that. + */ +const VALUE_TAKING_FLAGS = new Set([ + ...CLI_TO_SERVE_VALUE_FLAG.keys(), + '--serve-port', + '--serve-pairing-address', + '--serve-project-root', + '--user-data-dir', + '--environment', + '--pairing-code' +]) + +// Why: a CLI-form `serve` is not a serve launch when help was asked for. The AppImage redirect +// already hands these to the CLI (the same three tokens); without the refusal here, +// ` serve --help` binds a network-exposed runtime server with pairing on and prints nothing. +// Scoped to the subcommand form on purpose: `--serve` is the CLI's own contract and never carries +// `--help`, so widening this would risk the one path that already works. +const HELP_FLAGS = new Set(['--help', '-h', 'help']) + +function isFlagToken(token: string | undefined): boolean { + return Boolean(token && token.startsWith('-')) +} + +/** + * Value consumption for the two scans that walk argv looking for a position: a flag takes the next + * token only when that token is not itself flag-shaped. `normalizeServeModeArgv` consumes a strict + * subset of this (only the CLI value flags it translates), which is the property + * `findServeSubcommandIndex` documents below. + */ +function indexAfterToken(argv: readonly string[], i: number): number { + return i + (VALUE_TAKING_FLAGS.has(argv[i]!) && !isFlagToken(argv[i + 1]) ? 2 : 1) +} + +function requestsHelp(argv: readonly string[]): boolean { + let i = 1 + while (i < argv.length) { + const token = argv[i]! + if (token === '--') { + return false + } + // Skipping values matters for the bare `help` token: `serve --project-root help` names a + // directory, it does not ask for help. + if (HELP_FLAGS.has(token)) { + return true + } + i = indexAfterToken(argv, i) + } + return false +} + +/** + * Index of the CLI `serve` subcommand: the first positional token after flags + * (and their values). Option *values* named `serve` are never treated as the + * subcommand. + * + * What the tokens this skips must satisfy: they are a superset of the ones `normalizeServeModeArgv` + * consumes as values. That one-directional property is what keeps the serve index off a token the + * rewrite treats as a value — if the two ever disagreed, one half would swallow the `serve` token + * the other half is rewriting and `--serve` would never be injected: #12677 again in a new shape. + * Shrinking VALUE_TAKING_FLAGS to "restore symmetry" breaks it (see `--user-data-dir serve`). + */ +export function findServeSubcommandIndex(argv: readonly string[]): number { + if (requestsHelp(argv)) { + return -1 + } + let i = 1 + while (i < argv.length) { + const token = argv[i] + if (token === '--') { + return -1 + } + if (!isFlagToken(token)) { + return token === 'serve' ? i : -1 + } + i = indexAfterToken(argv, i) + } + return -1 +} + +/** True when argv already has `--serve` or a bare `serve` CLI subcommand. */ +export function argvRequestsServeMode(argv: readonly string[]): boolean { + return argv.includes(SERVE_FLAG) || findServeSubcommandIndex(argv) !== -1 +} + +/** + * Rewrite CLI-form `serve` invocations into the `--serve*` flag shape that + * `getServeOptions` already understands. Idempotent when already in flag form. + * + * Serve flags are translated whenever serve mode is requested, including the + * mixed `--serve --port 6768` form: leaving them untranslated is what makes a + * security-shaped flag like `--no-pairing` read as accepted while pairing stays + * on (#12677). + */ +export function normalizeServeModeArgv(argv: readonly string[]): string[] { + const serveIndex = findServeSubcommandIndex(argv) + if (serveIndex === -1 && !argv.includes(SERVE_FLAG)) { + return [...argv] + } + + const next: string[] = [] + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]! + if (i === serveIndex) { + next.push(SERVE_FLAG) + continue + } + if (token === '--') { + next.push(...argv.slice(i)) + break + } + // Why: the CLI accepts `--port=6768` as well as `--port 6768`, but + // getServeOptions only reads the next token, so `=` must be split apart. + const eq = token.indexOf('=') + const name = eq === -1 ? token : token.slice(0, eq) + // Why only the bare form: the CLI reads its serve booleans as `flags.get(name) === true` + // (src/cli/handlers/core.ts), so `--no-pairing=false` is NOT no-pairing there. Translating it + // anyway dropped the value and disabled pairing for an operator who wrote `=false` — the + // security-shaped inversion of #12677. Passing it through leaves pairing on, as the CLI does. + const booleanFlag = eq === -1 ? CLI_TO_SERVE_FLAG.get(name) : undefined + if (booleanFlag) { + next.push(booleanFlag) + continue + } + const valueFlag = CLI_TO_SERVE_VALUE_FLAG.get(name) + if (!valueFlag) { + next.push(token) + continue + } + if (eq !== -1) { + next.push(valueFlag, token.slice(eq + 1)) + continue + } + next.push(valueFlag) + const value = argv[i + 1] + if (value !== undefined && !isFlagToken(value)) { + next.push(value) + i += 1 + } + } + return next +} diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index b3058c91a66..e023d7895f5 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -85,6 +85,25 @@ describe('shouldActivateDesktopForSecondInstance', () => { expect(shouldActivateDesktopForSecondInstance(['/Applications/Orca.app/orca'])).toBe(true) }) + it('ignores a duplicate CLI-form serve launch the CLI redirect never rewrote', () => { + // Why: the documented systemd unit is ` serve --port 6768 …`; an extracted AppRun/binary + // start reaches Electron in that shape, so a flag-only check would open a window on the live server. + expect( + shouldActivateDesktopForSecondInstance([ + '/opt/orca/squashfs-root/orca-ide', + 'serve', + '--port', + '6768', + '--pairing-address', + '100.64.1.20' + ]) + ).toBe(false) + // A path argument that merely contains `serve` is still a desktop launch. + expect( + shouldActivateDesktopForSecondInstance(['/opt/orca/orca-ide', '/home/u/serve-repo']) + ).toBe(true) + }) + it('fails open when no argv is available', () => { expect(shouldActivateDesktopForSecondInstance([])).toBe(true) expect(shouldActivateDesktopForSecondInstance()).toBe(true) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 4a08fa61e73..69404b42e80 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -1,4 +1,5 @@ import type { App } from 'electron' +import { argvRequestsServeMode } from './serve-mode-argv' import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startup-diagnostics' export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = @@ -10,13 +11,12 @@ export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = // Why: stable "another process owns this profile" contract that systemd RestartPreventExitStatus= keys off; changing it silently un-fixes #11935. export const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3 -// Why: `serve` is a CLI subcommand, never Electron argv — an AppImage launched as `orca serve` exits -// at the CLI redirect before requesting the lock, and the CLI re-spawns the Electron child with `--serve`. -const SERVE_MODE_ARG = '--serve' - // Why: a duplicate `orca serve` is a supervisor artifact, not a user asking for a window; fail open when argv is unavailable. +// Why not `argv.includes('--serve')`: the documented systemd unit runs ` serve --port …`, so a +// duplicate start hands this handler CLI-form argv the CLI redirect never rewrote (#12677) — matching only +// the flag form would promote the live headless server to a desktop window, un-fixing #11935. export function shouldActivateDesktopForSecondInstance(argv: readonly string[] = []): boolean { - return !argv.includes(SERVE_MODE_ARG) + return !argvRequestsServeMode(argv) } /** diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 406949ac69e..b1da7e9e226 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -13,7 +13,7 @@ const { childStdinEndMock, resolveAuthorizedPathMock, fsMkdirMock, - fsReaddirMock, + fsOpendirMock, fsRmMock, fsWriteFileMock, fsOpenMock, @@ -46,7 +46,7 @@ const { }), resolveAuthorizedPathMock: vi.fn(), fsMkdirMock: vi.fn(), - fsReaddirMock: vi.fn(), + fsOpendirMock: vi.fn(), fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), fsOpenMock: vi.fn(), @@ -69,7 +69,7 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs/promises', () => ({ mkdir: fsMkdirMock, - readdir: fsReaddirMock, + opendir: fsOpendirMock, rm: fsRmMock, open: fsOpenMock, stat: fsStatMock, @@ -133,7 +133,6 @@ import { registerClipboardHandlers, setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' -import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() @@ -173,10 +172,6 @@ function trackPromiseSettled(promise: Promise): () => boolean { return () => settled } -function dirent(name: string, directory = true): { name: string; isDirectory: () => boolean } { - return { name, isDirectory: () => directory } -} - function shellIdListArray(childCount: number): Buffer { const value = Buffer.alloc(4 + 4 * (childCount + 1)) value.writeUInt32LE(childCount) @@ -194,8 +189,12 @@ describe('registerClipboardHandlers', () => { resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) fsMkdirMock.mockReset() fsMkdirMock.mockResolvedValue(undefined) - fsReaddirMock.mockReset() - fsReaddirMock.mockResolvedValue([]) + fsOpendirMock.mockReset() + // Why: handler registration kicks off the expired-staging sweep; an empty temp root keeps it inert. + fsOpendirMock.mockImplementation(async () => ({ + async *[Symbol.asyncIterator]() {}, + close: vi.fn().mockResolvedValue(undefined) + })) fsRmMock.mockReset() fsRmMock.mockResolvedValue(undefined) fsWriteFileMock.mockReset() @@ -307,33 +306,6 @@ describe('registerClipboardHandlers', () => { } }) - it('sweeps expired remote clipboard staging directories', async () => { - const nowMs = 1760000000000 - fsReaddirMock.mockResolvedValue([ - dirent('orca-clipboard-file-expired'), - dirent('orca-clipboard-file-fresh'), - dirent('orca-clipboard-file-plain-file', false), - dirent('unrelated-temp') - ]) - fsStatMock.mockImplementation(async (targetPath: string) => { - if (targetPath.endsWith('expired')) { - return { mtimeMs: nowMs - 60 * 60 * 1000 - 1 } - } - if (targetPath.endsWith('fresh')) { - return { mtimeMs: nowMs - 1000 } - } - throw new Error(`unexpected stat: ${targetPath}`) - }) - - await cleanupExpiredRemoteClipboardFiles(nowMs) - - expect(fsRmMock).toHaveBeenCalledTimes(1) - expect(fsRmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { - recursive: true, - force: true - }) - }) - it('materializes remote files before writing them to the OS clipboard', async () => { const provider = { stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), diff --git a/src/main/window/clipboard-remote-file-cleanup.test.ts b/src/main/window/clipboard-remote-file-cleanup.test.ts new file mode 100644 index 00000000000..56700cc025e --- /dev/null +++ b/src/main/window/clipboard-remote-file-cleanup.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' + +const { opendirMock, rmMock, statMock } = vi.hoisted(() => ({ + opendirMock: vi.fn(), + rmMock: vi.fn(), + statMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + opendir: opendirMock, + rm: rmMock, + stat: statMock +})) +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: vi.fn() +})) +vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) + +import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' + +const TTL_MS = 60 * 60 * 1000 +const NOW_MS = 1_760_000_000_000 + +function mockTempRoot(entries: Iterable<{ name: string; isDirectory: () => boolean }>): void { + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield* entries + }, + close: vi.fn().mockResolvedValue(undefined) + }) +} + +function* orcaStagingDirs(count: number): Generator<{ name: string; isDirectory: () => boolean }> { + for (let index = 0; index < count; index += 1) { + yield { name: `orca-clipboard-file-expired-${index}`, isDirectory: () => true } + } +} + +function* unrelatedTempEntries( + count: number +): Generator<{ name: string; isDirectory: () => boolean }> { + for (let index = 0; index < count; index += 1) { + // Mirrors a real temp root: mostly foreign entries, both files and directories. + yield { name: `unrelated-${index}`, isDirectory: () => index % 2 === 0 } + } +} + +describe('cleanupExpiredRemoteClipboardFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + rmMock.mockResolvedValue(undefined) + statMock.mockResolvedValue({ mtimeMs: NOW_MS - TTL_MS - 1 }) + }) + + it('streams all entries with at most eight cleanups in flight', async () => { + mockTempRoot(orcaStagingDirs(257)) + let active = 0 + let peak = 0 + statMock.mockImplementation(async () => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolve) => setImmediate(resolve)) + active -= 1 + return { mtimeMs: NOW_MS - TTL_MS - 1 } + }) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(rmMock).toHaveBeenCalledTimes(257) + expect(peak).toBe(8) + }) + + it('does no per-entry work for foreign temp-root entries', async () => { + mockTempRoot(unrelatedTempEntries(200_000)) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(statMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) + + it('acts on owned directories while the temp root is still being enumerated', async () => { + // Regression: the sweep materialized the whole temp root and built one promise + // per entry before doing any work, so a %TEMP% with ~1.2M unrelated entries + // froze the main process at startup (#12835). Streaming means an owned + // directory is swept before enumeration reaches the end. + let sweptDuringEnumeration = false + function* enumeration(): Generator<{ name: string; isDirectory: () => boolean }> { + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + for (const entry of unrelatedTempEntries(1_000)) { + sweptDuringEnumeration ||= rmMock.mock.calls.length > 0 + yield entry + } + } + mockTempRoot(enumeration()) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(sweptDuringEnumeration).toBe(true) + expect(rmMock).toHaveBeenCalledTimes(1) + }) + + it('removes only expired staging directories it owns', async () => { + function* interleaved(): Generator<{ name: string; isDirectory: () => boolean }> { + yield* unrelatedTempEntries(50_000) + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + yield* unrelatedTempEntries(50_000) + yield { name: 'orca-clipboard-file-fresh', isDirectory: () => true } + yield { name: 'orca-clipboard-file-plain-file', isDirectory: () => false } + } + mockTempRoot(interleaved()) + statMock.mockImplementation(async (targetPath: string) => ({ + mtimeMs: targetPath.endsWith('expired') ? NOW_MS - TTL_MS - 1 : NOW_MS - 1000 + })) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(statMock).toHaveBeenCalledTimes(2) + expect(rmMock).toHaveBeenCalledTimes(1) + expect(rmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { + recursive: true, + force: true + }) + }) + + it('closes the temp-root handle and still sweeps when enumeration fails midway', async () => { + const close = vi.fn().mockResolvedValue(undefined) + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + throw new Error('EIO') + }, + close + }) + + await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() + + expect(rmMock).toHaveBeenCalledTimes(1) + expect(close).toHaveBeenCalledTimes(1) + }) + + it('returns quietly when the temp root cannot be opened', async () => { + opendirMock.mockRejectedValue(new Error('EACCES')) + + await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() + + expect(statMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 052fce31ef3..2afc60cda30 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' -import type { Dirent } from 'node:fs' -import { mkdir, readdir, rm, stat } from 'node:fs/promises' +import type { Dir } from 'node:fs' +import { mkdir, opendir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { app } from 'electron' @@ -17,6 +17,7 @@ type RemoteClipboardFileDeps = Omit const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' +const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) @@ -79,32 +80,52 @@ export async function writeRemoteFileToClipboard({ } } +// Why: the OS temp root is shared with every other program and routinely holds +// millions of unrelated entries on long-lived machines, so this startup sweep +// streams it and only ever retains work for entries it actually owns. export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { const tempRoot = app.getPath('temp') - let entries: Dirent[] + let tempRootDir: Dir try { - entries = await readdir(tempRoot, { withFileTypes: true }) + tempRootDir = await opendir(tempRoot) } catch { return } - await Promise.all( - entries.map(async (entry) => { + const pending = new Set>() + try { + for await (const entry of tempRootDir) { if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { - return + continue } - const tempDir = join(tempRoot, entry.name) - try { - const tempStats = await stat(tempDir) - if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { - return - } - await rm(tempDir, { recursive: true, force: true }) - } catch { - // Why: stale staged SSH files should not make startup cleanup noisy. + const cleanup = cleanupExpiredRemoteClipboardDirectory(join(tempRoot, entry.name), nowMs) + pending.add(cleanup) + void cleanup.finally(() => pending.delete(cleanup)) + if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { + await Promise.race(pending) } - }) - ) + } + } catch { + // Why: a partial best-effort sweep beats failing startup on a temp-root read error. + } finally { + // Why: exhausting the iterator already closes the handle; closing again is harmless. + await tempRootDir.close().catch(() => undefined) + } + await Promise.all(pending) +} + +async function cleanupExpiredRemoteClipboardDirectory( + tempDir: string, + nowMs: number +): Promise { + try { + const tempStats = await stat(tempDir) + if (nowMs - tempStats.mtimeMs >= REMOTE_CLIPBOARD_FILE_TTL_MS) { + await rm(tempDir, { recursive: true, force: true }) + } + } catch { + // Why: stale staged SSH files should not make startup cleanup noisy. + } } function sanitizeLocalClipboardFilename(remoteBasename: string): string { diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 0976a922e16..7ffa56d7b53 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -231,6 +231,12 @@ describe('createOrFocusDashboardPopout', () => { expect(options).toEqual({ search: 'view=kanban' }) }) + it('opens on the current dashboard view by default', () => { + createOrFocusDashboardPopout(makeStore() as never) + + expect(instances[0].loadFile.mock.calls[0][1]).toEqual({ search: 'view=board' }) + }) + it('loads the dev server URL with the requested view when in dev', () => { isMock.dev = true vi.stubEnv('ELECTRON_RENDERER_URL', RENDERER_URL) @@ -249,6 +255,16 @@ describe('createOrFocusDashboardPopout', () => { expect(instances[0].focus).toHaveBeenCalledTimes(1) }) + it('switches an existing popout to an explicitly requested view', () => { + const store = makeStore() + createOrFocusDashboardPopout(store as never) + const win = instances[0] + + createOrFocusDashboardPopout(store as never, 'map') + + expect(win.webContents.send).toHaveBeenCalledWith('dashboard:viewRequested', 'map') + }) + it('trusts only the live popout webContents', () => { const win = createOrFocusDashboardPopout(makeStore() as never) as unknown as FakeWindow expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(true) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index 32fb00008ff..d23742f375a 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -18,7 +18,7 @@ const MIN_WIDTH = 480 const MIN_HEIGHT = 360 const DEFAULT_WIDTH = 960 const DEFAULT_HEIGHT = 720 -const DEFAULT_VIEW = 'kanban' +const DEFAULT_VIEW = 'board' const DASHBOARD_POPOUT_PARTITION = 'orca-dashboard-popout' // Why: singleton — the dashboard is a companion surface, so a second "Pop Out" @@ -138,7 +138,7 @@ function resolveRestoredBounds(store: Store | null): { */ export function createOrFocusDashboardPopout( store: Store | null, - view: string = DEFAULT_VIEW, + view?: string, options: { getKeybindings?: () => KeybindingOverrides | undefined } = {} ): BrowserWindow { if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) { @@ -146,9 +146,14 @@ export function createOrFocusDashboardPopout( dashboardPopoutWindow.restore() } dashboardPopoutWindow.focus() + if (view) { + dashboardPopoutWindow.webContents.send('dashboard:viewRequested', view) + } return dashboardPopoutWindow } + const initialView = view ?? DEFAULT_VIEW + const savedBounds = resolveRestoredBounds(store) const window = new BrowserWindow({ @@ -283,7 +288,7 @@ export function createOrFocusDashboardPopout( broadcastPopoutOpenChanged(false) }) - loadDashboardPopout(window, view) + loadDashboardPopout(window, initialView) return window } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0dc78393f28..a89debcce31 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -10,7 +10,12 @@ import type { } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { BrowserFindSource } from '../shared/browser-find-source' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -30,8 +35,12 @@ import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { ReleaseChannel } from '../shared/release-channel' import type { + ForgetRemovedWorktreesForExecutionHostArgs, + ForgetRemovedWorktreesForExecutionHostResult, + HostQualifiedKnownWorktreeResult, HostQualifiedDetectedWorktreeResult, LegacyDetectedWorktreeRequest, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs, ProviderRequestId } from '../shared/detected-worktree-provider-contract' @@ -423,7 +432,8 @@ import type { import type { DeveloperPermissionId, DeveloperPermissionRequestResult, - DeveloperPermissionState + DeveloperPermissionState, + LocalNetworkConnectionTestResult } from '../shared/developer-permissions-types' import type { ComputerUsePermissionId, @@ -489,6 +499,10 @@ import type { OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../shared/opencode-usage-types' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../shared/ai-vault-session-deletion' import type { AiVaultFirstUserPromptArgs, AiVaultFirstUserPromptResult, @@ -757,6 +771,10 @@ export type PtyManagementSession = { protocolVersion: number } +// 'severed': macOS can no longer attribute daemon terminals to Orca, so Accessibility/ +// Automation grants silently stop applying until the daemon is restarted (STA-3491). +export type PtyManagementMacTccAttributionHealth = 'intact' | 'severed' | 'unknown' + export type PtyManagementApi = { // `degraded`: daemon is alive but can't spawn fresh PTYs, so new terminals run locally without daemon persistence. listSessions: () => Promise<{ sessions: PtyManagementSession[]; degraded: boolean }> @@ -767,6 +785,7 @@ export type PtyManagementApi = { }> killOne: (args: { sessionId: string }) => Promise<{ success: boolean }> restart: () => Promise<{ success: boolean }> + macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth }> } export type ExportApi = { @@ -908,6 +927,8 @@ export type AiVaultApi = { listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise /** Full first user prompt for copy/reuse (re-parses one transcript). */ getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise + /** Moves a deletable session's transcript to the OS trash; local sessions only. */ + deleteSession: (args: AiVaultDeleteSessionArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ onWindowFocused: (callback: () => void) => () => void } @@ -1399,6 +1420,13 @@ export type PreloadApi = { ): Promise (args: LegacyDetectedWorktreeRequest): Promise } + listKnownForExecutionHost?: ( + args: ListKnownWorktreesForExecutionHostArgs + ) => Promise + /** Retires the persisted metadata an authoritative scan proved gone, so it stops feeding the read above. */ + forgetRemovedForExecutionHost?: ( + args: ForgetRemovedWorktreesForExecutionHostArgs + ) => Promise cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise @@ -2492,17 +2520,22 @@ export type PreloadApi = { ) => Promise } dashboard: { - openPopout: () => Promise + openPopout: (view?: 'board' | 'map') => Promise publishSnapshot: (snapshot: DashboardSnapshot) => Promise getPopoutOpen: () => Promise onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void onSnapshotRequested: (callback: () => void) => () => void onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void onAckAgent: (callback: (paneKey: string) => void) => () => void + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void) => () => void + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void requestSnapshot: () => Promise onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void + onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void revealAgent: (args: DashboardRevealAgentArgs) => Promise ackAgent: (paneKey: string) => Promise + spawnAgent: (args: DashboardSpawnAgentArgs) => Promise + sleepWorkspace: (args: DashboardSleepWorkspaceArgs) => Promise } terminalPreview: { connect: ( @@ -2532,6 +2565,10 @@ export type PreloadApi = { getStatus: () => Promise request: (args: { id: DeveloperPermissionId }) => Promise openSettings: (args: { id: DeveloperPermissionId }) => Promise + testLocalNetworkConnection: (args: { + host: string + port: number + }) => Promise } computerUsePermissions: { getStatus: () => Promise @@ -2898,6 +2935,8 @@ export type PreloadApi = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string requestToken?: string }) => Promise cancelStatus: (args: { requestToken: string }) => Promise @@ -3645,7 +3684,8 @@ export type PreloadApi = { qrDataUrl: string | null qrError?: 'encoding_failed' pairingUrl: string - endpoint: string + /** Null when no direct address was advertised — the QR pairs over Relay alone. */ + endpoint: string | null deviceId: string /** Mode the QR actually encodes. */ connectionMode: MobilePairingConnectionMode diff --git a/src/preload/index.ts b/src/preload/index.ts index 36e26870868..8c63d111d6d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,12 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -237,6 +242,10 @@ import type { AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../shared/ai-vault-session-deletion' import type { AiVaultFirstUserPromptArgs, AiVaultListArgs, @@ -766,6 +775,12 @@ const api = { listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + + forgetRemovedForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args), + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), listAll: () => ipcRenderer.invoke('worktrees:listAll'), @@ -1248,7 +1263,8 @@ const api = { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), - restart: () => ipcRenderer.invoke('pty:management:restart') + restart: () => ipcRenderer.invoke('pty:management:restart'), + macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') } }, @@ -2277,7 +2293,8 @@ const api = { dashboard: { // Open the pop-out dashboard window, or focus it if already open. - openPopout: (): Promise => ipcRenderer.invoke('dashboardPopout:open'), + openPopout: (view?: 'board' | 'map'): Promise => + ipcRenderer.invoke('dashboardPopout:open', view), // ── Producer side (main window) ────────────────────────────────────── publishSnapshot: (snapshot: DashboardSnapshot): Promise => @@ -2305,6 +2322,20 @@ const api = { ipcRenderer.on('ui:ackDashboardAgent', listener) return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) }, + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => + callback(args) + ipcRenderer.on('ui:spawnDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) + }, + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + args: DashboardSleepWorkspaceArgs + ): void => callback(args) + ipcRenderer.on('ui:sleepDashboardWorkspace', listener) + return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) + }, // ── Consumer side (pop-out window) ─────────────────────────────────── requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), @@ -2314,10 +2345,20 @@ const api = { ipcRenderer.on('dashboard:snapshot', listener) return () => ipcRenderer.removeListener('dashboard:snapshot', listener) }, + onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => + callback(view) + ipcRenderer.on('dashboard:viewRequested', listener) + return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) + }, revealAgent: (args: DashboardRevealAgentArgs): Promise => ipcRenderer.invoke('dashboardPopout:revealAgent', args), ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }) + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + spawnAgent: (args: DashboardSpawnAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:spawnAgent', args), + sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => + ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) }, terminalPreview: { @@ -2369,7 +2410,9 @@ const api = { request: (args: { id: string }): Promise => ipcRenderer.invoke('developerPermissions:request', args), openSettings: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:openSettings', args) + ipcRenderer.invoke('developerPermissions:openSettings', args), + testLocalNetworkConnection: (args: { host: string; port: number }): Promise => + ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) }, computerUsePermissions: { @@ -3230,6 +3273,7 @@ const api = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string }): Promise => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => @@ -4202,6 +4246,8 @@ const api = { ipcRenderer.invoke('aiVault:listSubagentSessions', args), getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), + deleteSession: (args: AiVaultDeleteSessionArgs): Promise => + ipcRenderer.invoke('aiVault:deleteSession', args), onWindowFocused: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('aiVault:windowFocused', listener) @@ -4650,7 +4696,8 @@ const api = { qrDataUrl: string | null qrError?: 'encoding_failed' pairingUrl: string - endpoint: string + /** Null when no direct address was advertised — the QR pairs over Relay alone. */ + endpoint: string | null deviceId: string connectionMode: MobilePairingConnectionMode } diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 7a81df602d2..1292d09e032 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -68,6 +68,30 @@ describe('native preload SSH authority forwarding', () => { >() }) + it('forwards host-qualified metadata-only worktree reads', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const args = { repoId: 'repo-1', executionHostId: 'ssh:ssh-1' as const } + + await api.worktrees.listKnownForExecutionHost?.(args) + + expect(invoke).toHaveBeenCalledWith('worktrees:listKnownForExecutionHost', args) + }) + + it('forwards metadata retirement for authoritatively removed worktrees', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const args = { + repoId: 'repo-1', + executionHostId: 'ssh:ssh-1' as const, + worktreeIds: ['repo-1::/remote/deleted'] + } + + await api.worktrees.forgetRemovedForExecutionHost?.(args) + + expect(invoke).toHaveBeenCalledWith('worktrees:forgetRemovedForExecutionHost', args) + }) + it('forwards full-pair get and push states without cloning away authority', async () => { const state: SshConnectionState = { targetId: 'ssh-1', diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 23dd28c35cb..9dc4812ef98 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -164,19 +164,23 @@ describe('FsHandler', () => { await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir()) }) - it('readDir returns sorted entries with directories first', async () => { + it('readDir returns entries directories-first in natural name order', async () => { mkdirSync(path.join(tmpDir, 'subdir')) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - writeFileSync(path.join(tmpDir, 'aaa.txt'), 'world') + for (const name of ['file.txt', '100 - b.txt', '99 - a.txt', '9 - c.txt']) { + writeFileSync(path.join(tmpDir, name), 'x') + } const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { name: string isDirectory: boolean }[] - expect(result[0].name).toBe('subdir') - expect(result[0].isDirectory).toBe(true) - expect(result.find((e) => e.name === 'file.txt')).toBeDefined() - expect(result.find((e) => e.name === 'aaa.txt')).toBeDefined() + expect(result[0]).toMatchObject({ name: 'subdir', isDirectory: true }) + expect(result.slice(1).map((e) => e.name)).toEqual([ + '9 - c.txt', + '99 - a.txt', + '100 - b.txt', + 'file.txt' + ]) }) it('readDir reports symlinked directories as directories', async () => { diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 9c146af1450..d07e2bf397f 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -5,6 +5,7 @@ import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' +import { sortDirEntries } from '../shared/file-name-sort' import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. @@ -148,12 +149,7 @@ export class FsHandler { isSymlink: entry.isSymbolicLink() })) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } private async readFile(params: Record) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 880e690ee54..60ea5913139 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -24,6 +24,11 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../shared/git-status-line-stats-cache' +import { + readGitBranchLineTotalMergeBaseParam, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import { buildBranchLineTotalInput } from './git-status-branch-line-total' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -74,11 +79,16 @@ export async function getStatusOp( ignoredPaths?: string[] didHitLimit?: boolean statusLength?: number + branchLineTotal?: GitBranchLineTotal }> { const worktreePath = params.worktreePath as string const lineStatsCacheKey = `relay\0${worktreePath}` const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true + // Why: untrusted RPC input spliced into a git argv — only an OID shape may pass. + const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam( + params.branchLineTotalMergeBase + ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) const conflictOperation = await detectConflictOperation(worktreePath) @@ -89,6 +99,8 @@ export async function getStatusOp( let ignoredPaths: string[] = [] let didHitLimit = false let statusLength = 0 + let statusSucceeded = false + let branchLineTotal: GitBranchLineTotal | undefined try { // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. @@ -118,6 +130,7 @@ export async function getStatusOp( ignoredPaths = parser.ignoredPaths statusLength = parser.statusLength didHitLimit = stoppedEarly + statusSucceeded = true const { upstreamName, upstreamAheadBehind } = parser.branch upstreamStatus = upstreamName ? { @@ -173,15 +186,26 @@ export async function getStatusOp( // Why: skip numstat after the limit to avoid reintroducing its cost. if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = buildBranchLineTotalInput( + git, + worktreePath, + entries, + // Why: a failed scan leaves the untracked list untrustworthy, so the total + // would under-count — omit it rather than publish a confident wrong number. + statusSucceeded ? branchLineTotalMergeBase : undefined, + options.signal + ) + // Why: passed in so the ranged diff runs alongside the per-area numstats, not after them. + ;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: params.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(git, worktreePath, entries, options.signal) - }) + recompute: () => attachLineStats(git, worktreePath, entries, options.signal), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) + })) } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -200,7 +224,8 @@ export async function getStatusOp( branch, upstreamStatus, ...(includeIgnored ? { ignoredPaths } : {}), - ...(didHitLimit ? { didHitLimit: true, statusLength } : {}) + ...(didHitLimit ? { didHitLimit: true, statusLength } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}) } } diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 6674500c6f9..0abc9f5d5be 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -89,6 +89,7 @@ import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { streamRelayGitStdout } from './git-stdout-stream' const execFileAsync = promisify(execFile) @@ -298,6 +299,7 @@ export class GitHandler { private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) } diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts new file mode 100644 index 00000000000..ed20750d217 --- /dev/null +++ b/src/relay/git-status-branch-line-total.test.ts @@ -0,0 +1,487 @@ +/** + * Relay-side contract for the status pass's branch line total: the chip is an + * exact number or absent, and costs nothing when nobody asked for it. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import type { GitExec } from './git-handler-ops' +import { getStatusOp } from './git-handler-status-ops' +import type { RelayGitStreamExec } from './git-stdout-stream' +import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' + +type GitCall = Parameters + +const execFileAsync = promisify(execFile) +const MERGE_BASE = '0123456789abcdef0123456789abcdef01234567' +const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98' +// Untracked additions ride on top of the ranged diff, so the fixture needs a real file. +const UNTRACKED_FILE = 'notes.md' +const UNTRACKED_LINES = 4 +// No fixture path here looks like test or generated code, so it is all source. +const NO_LINES = { added: 0, removed: 0 } +const STATUS_OUTPUT = [ + '# branch.oid 1111111111111111111111111111111111111111', + '# branch.head (detached)', + '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', + `? ${UNTRACKED_FILE}` +].join('\n') + +function isRangedNumstat(args: string[]): boolean { + return args.includes('diff') && args.includes('--numstat') && args.includes('-z') +} + +function rangedDiffCalls(calls: readonly GitCall[]): string[][] { + return calls.map(([args]) => args).filter((args) => isRangedNumstat(args)) +} + +function streamGitFromCapture(git: GitExec): RelayGitStreamExec { + return async (args, cwd, options) => { + const { stdout } = await git(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } + } +} + +/** Mock host; the fixture-repo cases below run real git instead. */ +function createMockGit(overrides: { + status?: string + ranged?: () => Promise<{ stdout: string; stderr: string }> + areaNumstat?: string +}) { + return vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) +} + +const realGitExec: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** + * Fork point → working tree: +3 tracked (2 committed, 1 unstaged), -2 from the + * file the branch deleted, +4 untracked. The pure rename contributes nothing. + */ +async function seedBranchFixture(repo: string): Promise { + await fs.rm(path.join(repo, UNTRACKED_FILE), { force: true }) + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\n') + await fs.writeFile(path.join(repo, 'doomed.txt'), 'x\ny\n') + await fs.writeFile(path.join(repo, 'old-name.txt'), 'stable\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\n') + await fs.rm(path.join(repo, 'doomed.txt')) + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'old-name.txt', 'new-name.txt']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\nf\n') + await fs.writeFile(path.join(repo, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + return mergeBase +} + +describe('getStatusOp branch line total', () => { + let tmpDir: string + + beforeEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-branch-line-total-')) + await fs.writeFile(path.join(tmpDir, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + }) + + afterEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('runs no ranged diff and returns no total when the merge base param is absent', async () => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) + + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + // The per-area numstats the CHANGES rows need still ran, so this is a + // zero-cost omission rather than a disabled status pass. + expect(result.entries).toContainEqual( + expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) + ) + }) + + it('sums the ranged diff and untracked additions against a real fixture repo', async () => { + const mergeBase = await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: mergeBase + }) + + expect(result.branchLineTotal).toEqual({ + added: 3 + UNTRACKED_LINES, + removed: 2, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) + expect(rangedDiffCalls(git.mock.calls)).toEqual([ + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] + ]) + }) + + it('omits the total when the status listing hit its limit', async () => { + const manyEntries = Array.from( + { length: 6 }, + (_, index) => `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt` + ).join('\n') + const git = createMockGit({ status: manyEntries }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + limit: 3, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.didHitLimit).toBe(true) + // The untracked list is truncated, so any total would silently under-count. + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + it('omits the total, never zero, when the ranged diff fails', async () => { + const git = createMockGit({ + ranged: () => Promise.reject(new Error('fatal: bad object 0123456789abcdef')) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // A failed ranged diff must not take the per-file rows down with it. + expect(result.entries).toContainEqual(expect.objectContaining({ path: 'src/a.ts', added: 3 })) + }) + + it('omits the total, never zero, for a well-formed but unknown merge-base oid', async () => { + await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + }) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.entries.length).toBeGreaterThan(0) + }) + + it('omits the total when the ranged diff exceeds its time budget', async () => { + const git = createMockGit({ + ranged: () => { + const error: Error & { killed?: boolean } = new Error('spawn git ETIMEDOUT') + error.killed = true + return Promise.reject(error) + } + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // The budget is handed to the subprocess rather than raced on a wall clock. + const rangedOptions = git.mock.calls.find(([args]) => isRangedNumstat(args))?.[2] + expect(rangedOptions).toMatchObject({ disableOptionalLocks: true, timeout: expect.any(Number) }) + }) + + it('omits the total when the status scan itself failed', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + throw new Error('fatal: not a git repository') + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.entries).toEqual([]) + // A failed scan yields an empty untracked list, which would read as "no + // untracked additions" rather than "unknown". + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + // Relay params are an untyped bag, so the merge base must be proven to be an + // object name before it can be spliced into an argv. + it.each([ + ['a flag-shaped value', '--upload-pack=x'], + ['a rev name', 'HEAD'], + ['a ref path', 'refs/heads/main'], + ['a range', `${MERGE_BASE}..HEAD`], + ['a number', 123], + ['null', null], + ['an empty string', ''] + ])('rejects %s before it reaches a git argv', async (_label, value) => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: value + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + for (const [args] of git.mock.calls) { + expect(args).not.toContain(String(value)) + } + }) + + it('answers a rejected merge base with the byte-identical no-merge-base response', async () => { + const gitWithout = createMockGit({}) + const withoutParam = await getStatusOp(gitWithout, streamGitFromCapture(gitWithout), { + worktreePath: tmpDir + }) + clearGitStatusLineStatsCache() + const gitRejected = createMockGit({}) + const rejectedParam = await getStatusOp(gitRejected, streamGitFromCapture(gitRejected), { + worktreePath: tmpDir, + branchLineTotalMergeBase: '--upload-pack=x' + }) + + expect(JSON.stringify(rejectedParam)).toBe(JSON.stringify(withoutParam)) + expect(gitRejected.mock.calls.map(([args]) => args)).toEqual( + gitWithout.mock.calls.map(([args]) => args) + ) + }) + + it('rejects an aborted scan instead of resolving a partial total', async () => { + const controller = new AbortController() + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + controller.abort() + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await expect( + getStatusOp( + git, + streamGitFromCapture(git), + { worktreePath: tmpDir, branchLineTotalMergeBase: MERGE_BASE }, + { signal: controller.signal } + ) + ).rejects.toThrow(/abort/i) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('reuses the cached total instead of re-running the ranged diff', async () => { + const git = createMockGit({}) + + const first = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const reused = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE, + reuseLineStats: true + }) + + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES + }) + expect(reused.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('coalesces concurrent status passes onto one ranged diff', async () => { + // The renderer's own in-flight refs only dedupe one renderer; a second + // window or an fs-watcher burst must not run the ranged diff twice. + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const [first, second] = await Promise.all([ + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }), + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + ]) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(first.branchLineTotal).toEqual(second.branchLineTotal) + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES + }) + }) + + it('recomputes rather than reusing a total measured against another fork point', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const moved = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: OTHER_MERGE_BASE, + reuseLineStats: true + }) + + expect(moved.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: OTHER_MERGE_BASE, + test: NO_LINES, + generated: NO_LINES + }) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2) + }) + + // Rule 1 of docs/reference/remote-wire-compatibility.md: a new optional field + // is safe only while every reader survives its absence. + describe('wire compatibility', () => { + it('drops the key from the wire payload so a reader sees undefined, not 0 or NaN', async () => { + const git = createMockGit({ ranged: () => Promise.reject(new Error('fatal: bad object')) }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + // An old server's payload and a new server's "not known exact" payload are + // the same thing on the wire: no key at all. + const overTheWire = JSON.parse(JSON.stringify(result)) as { + branchLineTotal?: { added: number; removed: number } + } + + expect(Object.hasOwn(overTheWire, 'branchLineTotal')).toBe(false) + expect(overTheWire.branchLineTotal).toBeUndefined() + expect(overTheWire.branchLineTotal?.added).toBeUndefined() + expect(overTheWire.branchLineTotal?.removed).toBeUndefined() + }) + + it('keeps the new request param out of the status argv an old server would run', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + const statusArgs = git.mock.calls + .map(([args]) => args) + .filter((args) => args.includes('status')) + expect(statusArgs).toHaveLength(1) + expect(statusArgs[0]).not.toContain(MERGE_BASE) + }) + + it('publishes finite counts when the ranged diff reports only binaries', async () => { + const git = createMockGit({ + ranged: () => Promise.resolve({ stdout: '-\t-\tlogo.png\n', stderr: '' }) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.branchLineTotal).toEqual({ + added: UNTRACKED_LINES, + removed: 0, + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES + }) + expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true) + expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true) + }) + }) +}) diff --git a/src/relay/git-status-branch-line-total.ts b/src/relay/git-status-branch-line-total.ts new file mode 100644 index 00000000000..c2fe6556a0e --- /dev/null +++ b/src/relay/git-status-branch-line-total.ts @@ -0,0 +1,44 @@ +/** + * Relay-side wiring for the status pass's branch line total. + * Why: separate file so git-handler-status-ops.ts stays under oxlint max-lines (300). + */ +import { + computeGitBranchLineTotal, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import type { GitExec } from './git-handler-ops' + +/** Undefined when no valid merge base was requested, which keeps the ranged diff entirely off. */ +export function buildBranchLineTotalInput( + git: GitExec, + worktreePath: string, + entries: Record[], + mergeBase: string | undefined, + signal?: AbortSignal +): { mergeBase: string; compute: () => Promise } | undefined { + if (!mergeBase) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Matches the `relay\0` line-stats cache-key convention. + hostKey: 'relay', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path as string), + runDiffNumstat: (args, diffSignal) => + git(args, worktreePath, { + // Why: a working-tree diff must not take index.lock away from terminal Git. + disableOptionalLocks: true, + signal: diffSignal, + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then(({ stdout }) => stdout), + ...(signal ? { signal } : {}) + }) + } +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9874acecd66..294fc4a6a3f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -48,6 +48,7 @@ import { isRemoteWorkspaceSnapshotApplyInProgress, useIpcEvents } from './hooks/ import { useAutomationDispatchEvents } from './hooks/useAutomationDispatchEvents' import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate' import { AgentHibernationGate } from './components/AgentHibernationGate' +import { AiVaultTabTitleSyncGate } from './components/AiVaultTabTitleSyncGate' import { ActivityTitlebarControls } from './components/activity/ActivityTitlebarControls' import Sidebar from './components/Sidebar' import { shutdownBufferCaptures } from './components/terminal-pane/shutdown-buffer-captures' @@ -92,7 +93,6 @@ import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' -import { useDashboardPopoutBridge } from './components/dashboard/useDashboardPopoutBridge' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { resolvePrimarySelectionMiddleClickPaste, @@ -389,6 +389,7 @@ const FloatingTerminalPanel = lazy(() => ) // Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on. const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) +const DashboardPopoutBridge = lazy(() => import('./components/dashboard/DashboardPopoutBridge')) // Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch. const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow')) @@ -757,8 +758,6 @@ function App(): React.JSX.Element { useEditorExternalWatch() useGlobalFileDrop() useAutoAckViewedAgent() - useDashboardPopoutBridge(settings?.experimentalAgentDashboardPopout === true) - useEffect(() => { return onOnboardingReopened(setOnboarding) }, []) @@ -2249,6 +2248,12 @@ function App(): React.JSX.Element { {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} + + {settings?.experimentalAgentDashboardPopout === true ? ( + + + + ) : null} {/* Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake. */} { + it('keeps the hidden link tooltip out of the fitted terminal height', () => { + expect(terminalCss).toMatch( + /\.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--pane-padding-y, 4px\)\);/s + ) + expect(terminalCss).toMatch( + /\.pane\[data-has-title\] \.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--orca-pane-title-height\)\);/s + ) + expect(terminalCss).toMatch( + /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s + ) + }) +}) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index ab45df2cc73..ee5e0af01f5 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -6,6 +6,11 @@ position: relative; } +/* Shared tooltip geometry for every terminal link provider. */ +:root { + --orca-terminal-link-tooltip-height: 25px; +} + /* Ensure pane manager root fills its absolutely-positioned container */ .pane-manager-root { width: 100% !important; @@ -497,6 +502,7 @@ so Ghostty-imported window-padding-x/y can be applied without inline styles. The fallback 4px preserves the legacy default. */ .xterm-container { + box-sizing: border-box; position: relative; width: calc(100% - var(--pane-padding-x, 4px)); height: calc(100% - var(--pane-padding-y, 4px)); @@ -516,6 +522,7 @@ /* Ghostty-style URL hover: glued to the pane's true bottom-left corner. */ .pane-link-tooltip { position: absolute; + box-sizing: border-box; bottom: 0; left: 0; z-index: 40; @@ -526,6 +533,8 @@ border-bottom: none; border-left: none; padding: 4px 8px; + height: var(--orca-terminal-link-tooltip-height); + line-height: 15px; max-width: 80%; overflow: hidden; text-overflow: ellipsis; diff --git a/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx b/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx new file mode 100644 index 00000000000..9b4af4b1ac1 --- /dev/null +++ b/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react' +import { startAiVaultTabTitleSync } from '@/lib/ai-vault-tab-title-sync' +import { useAppStore } from '@/store' + +export function AiVaultTabTitleSyncGate(): null { + useEffect( + () => + startAiVaultTabTitleSync({ + getState: useAppStore.getState, + subscribe: useAppStore.subscribe, + listSessions: (args) => window.api.aiVault.listSessions(args) + }), + [] + ) + return null +} diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 919b908f375..0e50c3bbc50 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -210,6 +210,7 @@ import { validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' import { getCheckConclusion, @@ -2834,6 +2835,14 @@ function PRActionsPanel({ } const previousState = localState setStatePending(true) + // Why: without registry authority a search-lagged Tasks refetch silently + // reverts this row to its pre-mutation state (STA-3343). + const authority = assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: nextState, + sourceContext + }) applyStatePatch(nextState) try { await runPullRequestStateUpdate({ @@ -2853,7 +2862,9 @@ function PRActionsPanel({ ) onMutated() } catch (err) { - applyStatePatch(previousState) + if (authority.revert()) { + applyStatePatch(previousState) + } toast.error( err instanceof Error ? err.message @@ -2913,6 +2924,13 @@ function PRActionsPanel({ toast.error(result.error) return } + // Why: merge is confirmed here; hold 'merged' against search-lagged refetches. + assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: 'merged', + sourceContext + }) applyStatePatch('merged') if (mergeTarget.kind === 'environment') { notifyWorkItemDetailsMutation( @@ -4175,6 +4193,9 @@ function GHEditSection({ return } const prevState = localState + // Why: without registry authority a search-lagged Tasks refetch silently + // reverts this row to its pre-mutation state (STA-3343). + let authority: { revert: () => boolean } | null = null run('state', { mutate: () => runIssueUpdate({ @@ -4189,14 +4210,22 @@ function GHEditSection({ : { state: newState } }), onOptimistic: () => { + authority = assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: newState, + sourceContext + }) onStateChange(newState) patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { - onStateChange(prevState) - patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: prevState }) + if (authority?.revert()) { + onStateChange(prevState) + patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded({ state: prevState }) + } }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('github-tasks') diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index a2f2d48f3e9..ae46f4dc0a7 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -11,6 +11,7 @@ import { CommandEmpty, CommandItem } from '@/components/ui/command' +import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' import { prepareQuickOpenFiles, rankQuickOpenFiles } from '@/components/quick-open-search' import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list' import { useModalReturnFocus } from '@/hooks/useModalReturnFocus' @@ -146,9 +147,7 @@ export default function QuickOpen(): React.JSX.Element | null { ) : ( filtered.map((item) => { - const lastSlash = item.path.lastIndexOf('/') - const dir = lastSlash >= 0 ? item.path.slice(0, lastSlash) : '' - const filename = item.path.slice(lastSlash + 1) + const { directory, filename } = splitTrailingSegment(item.path) const FileIcon = getFileTypeIcon(item.path) return ( @@ -156,11 +155,24 @@ export default function QuickOpen(): React.JSX.Element | null { key={item.path} value={item.path} onSelect={() => handleSelect(item.path)} - className="flex items-center gap-2 px-3 py-1.5" + className="min-w-0 p-0" > - - {filename} - {dir && {dir}} + {/* Why: the trigger is this inner element, not the CommandItem. + cmdk sets its own onPointerMove after spreading props, which + drops the one Radix needs to open the tooltip. */} + +
+ + {/* shrink-0 + max-w-full: the directory gives up all of its + width before the filename loses a character. */} + + {filename} + + {directory ? ( + {directory} + ) : null} +
+
) }) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index b9e03ecc492..05aec890947 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -261,6 +261,25 @@ describe('buildActivityEvents', () => { expect(threads[0].events[0].entry.prompt).toBe('First prompt') }) + it('does not turn a session boundary into an Agent finished event', () => { + const result = makeActivityResult({ + entries: { + [PANE_KEY]: { + ...makeWorkingEntryWithoutHistory(), + state: 'done', + prompt: '', + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'Real turn', startedAt: 1_000 }] + } + } + }) + + // Why: the displaced real completion stays visible; the idle SessionStart does not add a second finish. + expect(result.events).toHaveLength(1) + expect(result.events[0]).toMatchObject({ state: 'done', timestamp: 1_000 }) + expect(result.events[0].entry.prompt).toBe('Real turn') + }) + it('does not keep showing a stale live agent as running', () => { const result = makeActivityResult({ entries: { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 8e732ee044c..8c8058fbeeb 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -590,7 +590,8 @@ function appendActivityEventsForEntry(args: { }) } - if (!isActivityEventState(args.entry.state)) { + // Why: SessionStart creates an idle row, not an "Agent finished" activity event (STA-3386). + if (!isActivityEventState(args.entry.state) || args.entry.sessionBoundary === true) { return } appendActivityEvent({ diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts new file mode 100644 index 00000000000..5c28a132762 --- /dev/null +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { countActivityUnread } from './useActivityUnreadCount' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function makeEntry(overrides: Partial): AgentStatusEntry { + return { + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + paneKey: PANE, + agentType: 'claude', + stateHistory: [], + ...overrides + } +} + +function makeSource(entry: AgentStatusEntry, ackAt = 0) { + return { + acknowledgedAgentsByPaneKey: { [PANE]: ackAt }, + agentStatusByPaneKey: { [PANE]: entry }, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + worktreesByRepo: {} + } +} + +describe('countActivityUnread session-boundary rows (STA-3386)', () => { + it('does not count a session-boundary done as unread in either mode', () => { + const source = makeSource(makeEntry({ sessionBoundary: true })) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source, 'agent-events')).toBe(0) + }) + + it('keeps counting a real completion displaced into history by a session boundary', () => { + // Why: agent finished (unacknowledged), then the user resumed the session — the + // boundary row replaces the live done but the finish must stay unread in both badges. + const source = makeSource( + makeEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] + }) + ) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + expect(countActivityUnread(source, 'agent-events')).toBe(1) + }) + + it('stops counting the displaced completion once acknowledged', () => { + const source = makeSource( + makeEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] + }), + 1_500 + ) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source, 'agent-events')).toBe(0) + }) + + it('still counts an ordinary unacknowledged done in sidebar-badge mode', () => { + const source = makeSource(makeEntry({})) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + }) +}) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index b496f9146cd..91b51c4455e 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -60,8 +60,22 @@ export function countActivityUnread( } } } - if (isUnreadAgentState(entry.state) && ackAt < entry.stateStartedAt) { + // Why: a session-boundary done is an idle connect (STA-3386), not an event to read. + // History never contains a boundary, but it DOES keep the real completion a boundary + // displaced (the slice pushes it on done→done), so sidebar-badge mode — which skips the + // history loop above — must still count that displaced completion or the badge silently + // drops an unacknowledged finish the moment its session is resumed. + if ( + isUnreadAgentState(entry.state) && + entry.sessionBoundary !== true && + ackAt < entry.stateStartedAt + ) { count += 1 + } else if (mode === 'sidebar-badge' && entry.state === 'done' && entry.sessionBoundary) { + const displaced = entry.stateHistory.at(-1) + if (displaced && isUnreadAgentState(displaced.state) && ackAt < displaced.startedAt) { + count += 1 + } } } diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 97f15d844a6..e8a33f4dcd0 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -46,12 +46,12 @@ import { waitForAutomationRerunPendingVisibility } from './automation-run-view-state' import { - automationRunMatchesPaneKey, buildAutomationRunOpenLayout, canOpenAutomationRunOpenTarget, getAutomationRunOpenTabId, resolveAutomationRunOpenTarget } from './automation-run-open-target' +import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' import { AutomationEditorDialog, @@ -903,21 +903,12 @@ export default function AutomationsPage(): React.JSX.Element { if (dispatchedAt === null) { return false } - const liveDone = Object.entries(agentStatusByPaneKey).some( - ([paneKey, entry]) => - automationRunMatchesPaneKey(run, paneKey) && - entry.state === 'done' && - entry.updatedAt >= dispatchedAt - ) - if (liveDone) { - return true - } - return Object.entries(retainedAgentsByPaneKey).some( - ([paneKey, retained]) => - automationRunMatchesPaneKey(run, paneKey) && - retained.entry.state === 'done' && - retained.entry.updatedAt >= dispatchedAt - ) + return hasAutomationRunCompletionEvidence({ + run, + dispatchedAt, + agentStatusByPaneKey, + retainedAgentsByPaneKey + }) }) if (completedRuns.length === 0) { return diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts new file mode 100644 index 00000000000..f46426337a8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function run(): AutomationRun { + return { + id: 'run-1', + automationId: 'automation-1', + title: 'Run 1', + scheduledFor: 1, + status: 'dispatched', + trigger: 'manual', + workspaceId: 'wt-1', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'tab-1', + terminalPaneKey: PANE, + terminalPtyId: 'pty-1', + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: 1_000, + dispatchedAt: 1_000, + createdAt: 1_000 + } +} + +function entry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: PANE, + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +function retained(status: AgentStatusEntry): RetainedAgentEntry { + return { + entry: status, + worktreeId: 'wt-1', + tab: { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1_000 + }, + agentType: 'claude', + startedAt: status.stateStartedAt + } +} + +describe('automation run completion evidence (STA-3386)', () => { + it('ignores live and retained session-boundary done rows', () => { + const boundary = entry({ sessionBoundary: true }) + expect( + hasAutomationRunCompletionEvidence({ + run: run(), + dispatchedAt: 1_000, + agentStatusByPaneKey: { [PANE]: boundary }, + retainedAgentsByPaneKey: { [PANE]: retained(boundary) } + }) + ).toBe(false) + }) + + it('accepts a real done after dispatch', () => { + expect( + hasAutomationRunCompletionEvidence({ + run: run(), + dispatchedAt: 1_000, + agentStatusByPaneKey: { [PANE]: entry() }, + retainedAgentsByPaneKey: {} + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.ts new file mode 100644 index 00000000000..26c0b0ffc93 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-completion-evidence.ts @@ -0,0 +1,38 @@ +import type { AutomationRun } from '../../../../shared/automations-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import { automationRunMatchesPaneKey } from './automation-run-open-target' + +function isCompletionAfterDispatch(entry: AgentStatusEntry, dispatchedAt: number): boolean { + return entry.state === 'done' && entry.sessionBoundary !== true && entry.updatedAt >= dispatchedAt +} + +export function hasAutomationRunCompletionEvidence({ + run, + dispatchedAt, + agentStatusByPaneKey, + retainedAgentsByPaneKey +}: { + run: AutomationRun + dispatchedAt: number + agentStatusByPaneKey: Record + retainedAgentsByPaneKey: Record +}): boolean { + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { + if ( + automationRunMatchesPaneKey(run, paneKey) && + isCompletionAfterDispatch(entry, dispatchedAt) + ) { + return true + } + } + for (const [paneKey, retained] of Object.entries(retainedAgentsByPaneKey)) { + if ( + automationRunMatchesPaneKey(run, paneKey) && + isCompletionAfterDispatch(retained.entry, dispatchedAt) + ) { + return true + } + } + return false +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index a452757ecc4..d1aca3c49b6 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -197,6 +197,7 @@ import { import { subscribeBrowserSystemResume } from './browser-system-resume' import { type BrowserReloadTrigger, + reloadBrowserPageWebview, resolveBrowserReloadButtonLabelKind, resolveBrowserReloadIntent } from './browser-reload-action' @@ -2941,6 +2942,20 @@ function BrowserPagePane({ () => ({ loading: browserTab.loading, loadErrorCode: browserTab.loadError?.code ?? null }), [browserTab.loading, browserTab.loadError] ) + const reloadWebviewOrRecoverGuest = useCallback( + (ignoreCache: boolean) => { + const webview = webviewRef.current + if (!webview) { + return + } + if (reloadBrowserPageWebview(webview, { ignoreCache }) === 'guest-missing') { + // Why: reload cannot revive a destroyed guest (STA-3448) — recreate it instead. + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryGuestRecoveryRef.current() + } + }, + [browserTab.id] + ) const runReloadTrigger = useCallback( (trigger: BrowserReloadTrigger) => { const webview = webviewRef.current @@ -2959,14 +2974,14 @@ function BrowserPagePane({ retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current) break case 'hard-reload': - webview.reloadIgnoringCache() + reloadWebviewOrRecoverGuest(true) break case 'reload': - webview.reload() + reloadWebviewOrRecoverGuest(false) break } }, - [browserTab, reloadState] + [browserTab, reloadState, reloadWebviewOrRecoverGuest] ) // Keep the accessible name honest: the same button is Stop mid-load and Retry after a failure. @@ -3454,7 +3469,12 @@ function BrowserPagePane({ return false } addressBarInputRef.current?.blur() - webview.focus() + try { + webview.focus() + } catch { + // Why: WebViewElement.focus() reads null internals once the guest is destroyed (STA-3448). + return false + } return document.activeElement === webview }, []) @@ -3660,15 +3680,11 @@ function BrowserPagePane({ } e.preventDefault() e.stopPropagation() - if (isHardReload) { - webviewRef.current?.reloadIgnoringCache() - } else { - webviewRef.current?.reload() - } + reloadWebviewOrRecoverGuest(isHardReload) } window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) - }, [isActive, keybindings]) + }, [isActive, keybindings, reloadWebviewOrRecoverGuest]) // Cmd/Ctrl+R — reload (IPC path: focus inside webview guest) // Why: a focused guest is a separate Chromium process, so main forwards the chord back here. @@ -3677,9 +3693,9 @@ function BrowserPagePane({ return } return window.api.ui.onReloadBrowserPage(() => { - webviewRef.current?.reload() + reloadWebviewOrRecoverGuest(false) }) - }, [isActive]) + }, [isActive, reloadWebviewOrRecoverGuest]) useEffect(() => { if (!isActive) { @@ -3716,9 +3732,9 @@ function BrowserPagePane({ return } return window.api.ui.onHardReloadBrowserPage(() => { - webviewRef.current?.reloadIgnoringCache() + reloadWebviewOrRecoverGuest(true) }) - }, [isActive]) + }, [isActive, reloadWebviewOrRecoverGuest]) useEffect(() => { onUpdatePageStateRef.current = onUpdatePageState @@ -4220,9 +4236,17 @@ function BrowserPagePane({ validateVisibleGuestRegistrationRef.current = guestRecovery.validateAfterResume retryGuestRecoveryRef.current = guestRecovery.retryRecovery + const handleGuestDestroyed = (): void => { + // Why: a guest can be destroyed without render-process-gone (detach/reattach race, + // guest-side close). Skip intentional teardown, where the element already left the DOM. + if (webview.isConnected) { + guestRecovery.recoverRenderer() + } + } webview.addEventListener('did-attach', handleDidAttach) webview.addEventListener('dom-ready', handleDomReady) webview.addEventListener('render-process-gone', guestRecovery.recoverRenderer) + webview.addEventListener('destroyed', handleGuestDestroyed) webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) webview.addEventListener('did-start-navigation', handleDidStartNavigation) @@ -4259,6 +4283,7 @@ function BrowserPagePane({ webview.removeEventListener('did-attach', handleDidAttach) webview.removeEventListener('dom-ready', handleDomReady) webview.removeEventListener('render-process-gone', guestRecovery.recoverRenderer) + webview.removeEventListener('destroyed', handleGuestDestroyed) webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) webview.removeEventListener('did-start-navigation', handleDidStartNavigation) @@ -5161,7 +5186,7 @@ function BrowserPagePane({ role="menuitem" className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14" onClick={() => { - webviewRef.current?.reload() + reloadWebviewOrRecoverGuest(false) setContextMenu(null) }} > diff --git a/src/renderer/src/components/browser-pane/browser-reload-action.test.ts b/src/renderer/src/components/browser-pane/browser-reload-action.test.ts index ec14e666826..2f9748767a1 100644 --- a/src/renderer/src/components/browser-pane/browser-reload-action.test.ts +++ b/src/renderer/src/components/browser-pane/browser-reload-action.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' import { + reloadBrowserPageWebview, resolveBrowserReloadButtonLabelKind, resolveBrowserReloadIntent } from './browser-reload-action' @@ -51,3 +52,62 @@ describe('resolveBrowserReloadButtonLabelKind', () => { expect(resolveBrowserReloadButtonLabelKind(guestFailed)).toBe('retry') }) }) + +describe('reloadBrowserPageWebview', () => { + function createWebview(overrides: Partial = {}): Electron.WebviewTag { + return { + getWebContentsId: vi.fn(() => 42), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), + ...overrides + } as unknown as Electron.WebviewTag + } + + it('reloads a live guest, honoring the cache flag', () => { + const webview = createWebview() + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('reloaded') + expect(webview.reload).toHaveBeenCalledTimes(1) + + expect(reloadBrowserPageWebview(webview, { ignoreCache: true })).toBe('reloaded') + expect(webview.reloadIgnoringCache).toHaveBeenCalledTimes(1) + }) + + // Why: a destroyed guest makes reload() throw uncaught (STA-3448) — callers must recreate the guest instead. + it('reports a missing guest without touching reload', () => { + const webview = createWebview({ + getWebContentsId: vi.fn(() => { + throw new Error('WebContents is destroyed') + }) + }) + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('guest-missing') + expect(webview.reload).not.toHaveBeenCalled() + }) + + it('reports a guest destroyed between the liveness probe and reload', () => { + const getWebContentsId = vi + .fn<() => number>() + .mockReturnValueOnce(42) + .mockImplementation(() => { + throw new Error('WebContents is destroyed') + }) + const webview = createWebview({ + getWebContentsId, + reload: vi.fn(() => { + throw new Error('The WebView must be attached to the DOM') + }) + }) + + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('guest-missing') + expect(getWebContentsId).toHaveBeenCalledTimes(2) + }) + + // Why: pre-dom-ready reload throws too, but a live guest is already loading — recovery must not replace it. + it('reports not-ready when a live guest rejects the reload', () => { + const webview = createWebview({ + reload: vi.fn(() => { + throw new Error('The WebView must be attached to the DOM') + }) + }) + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('not-ready') + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-reload-action.ts b/src/renderer/src/components/browser-pane/browser-reload-action.ts index 2bebeaa9cd9..b4ecb6d9b4f 100644 --- a/src/renderer/src/components/browser-pane/browser-reload-action.ts +++ b/src/renderer/src/components/browser-pane/browser-reload-action.ts @@ -34,6 +34,40 @@ export function resolveBrowserReloadIntent( return trigger === 'hard-reload' ? 'hard-reload' : 'reload' } +export type BrowserPageWebviewReloadResult = 'reloaded' | 'guest-missing' | 'not-ready' + +/** + * Why: reload on a guestless webview throws uncaught ("The WebView must be attached to the + * DOM…", STA-3448). getWebContentsId() only needs an attached guest, so it separates a dead + * guest (route to guest recovery) from a live pre-dom-ready one (a load is already in flight). + */ +export function reloadBrowserPageWebview( + webview: Electron.WebviewTag, + { ignoreCache }: { ignoreCache: boolean } +): BrowserPageWebviewReloadResult { + try { + webview.getWebContentsId() + } catch { + return 'guest-missing' + } + try { + if (ignoreCache) { + webview.reloadIgnoringCache() + } else { + webview.reload() + } + } catch { + try { + webview.getWebContentsId() + } catch { + // Why: the guest can be destroyed between the liveness probe and reload. + return 'guest-missing' + } + return 'not-ready' + } + return 'reloaded' +} + /** Accessible name for the toolbar button, which is Stop mid-load and Retry after a failure. */ export type BrowserReloadButtonLabelKind = 'stop' | 'retry' | 'reload' diff --git a/src/renderer/src/components/browser-pane/webview-registry.test.ts b/src/renderer/src/components/browser-pane/webview-registry.test.ts index b4abfee6b7e..c1ec7181e85 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.test.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.test.ts @@ -165,6 +165,45 @@ describe('webview registry drag listeners', () => { expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) }) + it('flags renderer recovery when the guest is destroyed under a still-attached webview', async () => { + const { isBrowserPageRendererRecoveryPending, registerPersistentWebview } = + await import('./webview-registry') + const webview = createWebview({ isConnected: true }) + registerPersistentWebview('page-1', webview) + + webview.dispatchEvent(new Event('destroyed')) + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(true) + + webview.dispatchEvent(new Event('dom-ready')) + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + + it('ignores guest destruction caused by intentional webview removal', async () => { + const { isBrowserPageRendererRecoveryPending, registerPersistentWebview } = + await import('./webview-registry') + const webview = createWebview({ isConnected: false }) + registerPersistentWebview('page-1', webview) + + webview.dispatchEvent(new Event('destroyed')) + + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + + it('stops listening for guest destruction after unregistration', async () => { + const { + isBrowserPageRendererRecoveryPending, + registerPersistentWebview, + unregisterPersistentWebview + } = await import('./webview-registry') + const webview = createWebview({ isConnected: true }) + registerPersistentWebview('page-1', webview) + unregisterPersistentWebview('page-1') + + webview.dispatchEvent(new Event('destroyed')) + + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + it('preserves the viewport and zoom only while replacing a guest', async () => { const { destroyPersistentWebview, registerPersistentWebview, replacePersistentWebview } = await import('./webview-registry') diff --git a/src/renderer/src/components/browser-pane/webview-registry.ts b/src/renderer/src/components/browser-pane/webview-registry.ts index 3ce94e15dad..f64f0dfc0e3 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.ts @@ -27,6 +27,7 @@ const webviewLifecycleListeners = new Map< webview: Electron.WebviewTag onRendererGone: EventListener onRendererReady: EventListener + onGuestDestroyed: EventListener } >() @@ -163,6 +164,7 @@ export function registerPersistentWebview( previousListeners.onRendererGone ) previousListeners.webview.removeEventListener('dom-ready', previousListeners.onRendererReady) + previousListeners.webview.removeEventListener('destroyed', previousListeners.onGuestDestroyed) } const onRendererGone = (): void => { rendererRecoveryPendingPageIds.add(browserTabId) @@ -170,9 +172,22 @@ export function registerPersistentWebview( const onRendererReady = (): void => { rendererRecoveryPendingPageIds.delete(browserTabId) } + const onGuestDestroyed = (): void => { + // Why: 'destroyed' also fires after an intentional webview.remove(); only a + // still-attached element means the guest died under a live tab (STA-3448). + if (webview.isConnected) { + rendererRecoveryPendingPageIds.add(browserTabId) + } + } webview.addEventListener('render-process-gone', onRendererGone) webview.addEventListener('dom-ready', onRendererReady) - webviewLifecycleListeners.set(browserTabId, { webview, onRendererGone, onRendererReady }) + webview.addEventListener('destroyed', onGuestDestroyed) + webviewLifecycleListeners.set(browserTabId, { + webview, + onRendererGone, + onRendererReady, + onGuestDestroyed + }) webviewRegistry.set(browserTabId, webview) applyCurrentDragPassthroughToWebview(webview) ensureDragListeners() @@ -187,6 +202,7 @@ export function unregisterPersistentWebview(browserTabId: string): void { lifecycleListeners.onRendererGone ) lifecycleListeners.webview.removeEventListener('dom-ready', lifecycleListeners.onRendererReady) + lifecycleListeners.webview.removeEventListener('destroyed', lifecycleListeners.onGuestDestroyed) webviewLifecycleListeners.delete(browserTabId) } rendererRecoveryPendingPageIds.delete(browserTabId) diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index e4b26441bde..d0fcc15636c 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -1,4 +1,9 @@ import { isCmdJPaletteQueryTooLarge } from './palette-results' +import { + cmdJPaletteTokenScore, + normalizeCmdJPaletteQuery, + uniqueNormalizedCmdJPaletteKeywords +} from './palette-query-tokens' import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' import { @@ -39,73 +44,6 @@ type RankedProjectResult = { const PROJECT_GROUP_ALIASES = ['group', 'repo group'] const PROJECT_ALIASES = ['project', 'repo'] -function normalizeQuery(value: string): string { - let normalized = '' - let pendingWhitespace = false - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (isCmdJPaletteWhitespace(code)) { - pendingWhitespace = normalized.length > 0 - continue - } - if (pendingWhitespace) { - normalized += ' ' - pendingWhitespace = false - } - normalized += value.charAt(index).toLowerCase() - } - return normalized -} - -function isCmdJPaletteWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -function uniqueNormalized(values: readonly string[]): string[] { - return [...new Set(values.map(normalizeQuery).filter(Boolean))] -} - -function tokenize(value: string): string[] { - return normalizeQuery(value) - .split(/[^a-z0-9]+/) - .filter(Boolean) -} - -function tokenScore(query: string, values: readonly string[]): number { - const candidateTokens = values.flatMap(tokenize) - if (candidateTokens.length === 0) { - return 0 - } - - let score = 0 - for (const queryToken of tokenize(query)) { - let best = 0 - for (const candidateToken of candidateTokens) { - if (candidateToken === queryToken) { - best = Math.max(best, 3) - } else if (candidateToken.startsWith(queryToken)) { - best = Math.max(best, 2) - } else if (candidateToken.includes(queryToken)) { - best = Math.max(best, 1) - } - } - score += best - } - return score -} - function buildCmdJProjectSearchCandidates({ projectGroups, repos, @@ -134,7 +72,7 @@ function buildCmdJProjectSearchCandidates({ ), rowKey: getProjectGroupHeaderKey(group.id), order, - keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES]) + keywords: uniqueNormalizedCmdJPaletteKeywords([group.name, ...PROJECT_GROUP_ALIASES]) }) }) @@ -156,7 +94,11 @@ function buildCmdJProjectSearchCandidates({ rowKey: target.key, repo: target.repo, order: projectGroups.length + repoIndex, - keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES]) + keywords: uniqueNormalizedCmdJPaletteKeywords([ + target.label, + repo.displayName, + ...PROJECT_ALIASES + ]) }) }) @@ -191,7 +133,7 @@ function projectRankingForCandidate( query: string, candidate: CmdJProjectSearchResult ): RankedProjectResult | null { - const title = normalizeQuery(candidate.title) + const title = normalizeCmdJPaletteQuery(candidate.title) if (query === title) { return { result: candidate, rule: 1, score: 0 } } @@ -199,13 +141,13 @@ function projectRankingForCandidate( return { result: candidate, rule: 2, score: 0 } } const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES - if (aliasKeywords.map(normalizeQuery).includes(query)) { + if (aliasKeywords.map(normalizeCmdJPaletteQuery).includes(query)) { return { result: candidate, rule: 3, score: 0 } } if (candidate.keywords.some((keyword) => keyword.startsWith(query))) { return { result: candidate, rule: 4, score: 0 } } - const score = tokenScore(query, [candidate.title, ...candidate.keywords]) + const score = cmdJPaletteTokenScore(query, [candidate.title, ...candidate.keywords]) return score > 0 ? { result: candidate, rule: 5, score } : null } @@ -242,7 +184,7 @@ export function searchCmdJProjectResults({ if (isCmdJPaletteQueryTooLarge(query)) { return [] } - const normalizedQuery = normalizeQuery(query) + const normalizedQuery = normalizeCmdJPaletteQuery(query) // Why: project/group rows sit after worktree matches, so one-character // searches would add broad noisy navigation targets before intent is clear. if (normalizedQuery.length < 2) { diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts new file mode 100644 index 00000000000..3f829ace14a --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { normalizeCmdJPaletteQuery, tokenizeCmdJPaletteQuery } from './palette-query-tokens' + +describe('Cmd+J palette query normalization', () => { + it('lowercases supplementary-plane characters as whole code points', () => { + // Why: code-unit iteration split '\u{10400}' into surrogate halves, which lowercase to + // themselves, so the query never matched its lowercase counterpart. + expect(normalizeCmdJPaletteQuery('\u{10400} HELLO')).toBe('\u{10428} hello') + expect(tokenizeCmdJPaletteQuery('\u{10400} HELLO')).toEqual(['\u{10428}', 'hello']) + }) + + it('collapses surrounding and repeated whitespace', () => { + expect(normalizeCmdJPaletteQuery(' New\n\tTerminal ')).toBe('new terminal') + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.ts new file mode 100644 index 00000000000..3ba9068b0c1 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.ts @@ -0,0 +1,116 @@ +// Why: the middle band and the project band scored queries with byte-identical copies of +// this logic, so ranking fixes only ever landed in one of the two. + +function isCmdJPaletteWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +// Why: iterating code units would lowercase surrogate halves separately, leaving +// supplementary-plane characters uncased and unmatchable. +export function normalizeCmdJPaletteQuery(value: string): string { + let normalized = '' + let pendingWhitespace = false + for (const character of value) { + if (isCmdJPaletteWhitespace(character.codePointAt(0) ?? 0)) { + pendingWhitespace = normalized.length > 0 + continue + } + if (pendingWhitespace) { + normalized += ' ' + pendingWhitespace = false + } + normalized += character.toLowerCase() + } + return normalized +} + +export function uniqueNormalizedCmdJPaletteKeywords(values: readonly string[]): string[] { + return [...new Set(values.map(normalizeCmdJPaletteQuery).filter(Boolean))] +} + +// Why: splitting on non-ASCII would drop CJK and accented words entirely, so a localized +// query would silently skip the coverage rule below instead of failing it. +export function tokenizeCmdJPaletteQuery(value: string): string[] { + return normalizeCmdJPaletteQuery(value) + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean) +} + +// Why: navigation filler carries no intent ("open ssh settings" means "ssh settings"), so an +// unmatched filler word must not count against a candidate's coverage. +const CMD_J_QUERY_FILLER_TOKENS = new Set([ + 'a', + 'an', + 'and', + 'change', + 'edit', + 'for', + 'from', + 'go', + 'goto', + 'in', + 'into', + 'jump', + 'me', + 'my', + 'of', + 'on', + 'open', + 'please', + 'set', + 'show', + 'the', + 'to', + 'view', + 'with' +]) + +export function cmdJPaletteTokenScore(query: string, values: readonly string[]): number { + const candidateTokens = values.flatMap(tokenizeCmdJPaletteQuery) + if (candidateTokens.length === 0) { + return 0 + } + + let score = 0 + let meaningful = 0 + let covered = 0 + for (const queryToken of tokenizeCmdJPaletteQuery(query)) { + let best = 0 + for (const candidateToken of candidateTokens) { + if (candidateToken === queryToken) { + best = Math.max(best, 3) + } else if (candidateToken.startsWith(queryToken)) { + best = Math.max(best, 2) + } else if (candidateToken.includes(queryToken)) { + best = Math.max(best, 1) + } + } + score += best + if (CMD_J_QUERY_FILLER_TOKENS.has(queryToken)) { + continue + } + meaningful += 1 + if (best > 0) { + covered += 1 + } + } + + // Why: "linear triage" matched the Linear pane as strongly as "linear" did, so a candidate + // now has to cover most of what was typed, not just one word of it. + if (meaningful > 0 && covered * 2 <= meaningful) { + return 0 + } + return score +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 73049bcc149..bf875ebe9a2 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -208,6 +208,66 @@ describe('Cmd+J palette middle-band ranking', () => { }) }) + it('drops candidates that cover a minority of the words typed', () => { + // Why: "linear triage" used to surface the Linear and Integrations panes on the + // "linear" token alone, with the unmatched word costing nothing. See screenshot report. + const integrationSections: SettingsNavSection[] = [ + { + id: 'linear', + title: 'Linear', + description: 'How Linear works in Orca.', + icon: Settings, + searchEntries: [], + group: 'capabilities' + }, + { + id: 'integrations', + title: 'Integrations', + description: 'Connect GitHub, GitLab, and Linear.', + icon: Settings, + searchEntries: [], + group: 'setup' + } + ] + const rank = (query: string): string[] => + rankCmdJMiddleResults({ + query, + settingsResults: buildCmdJSettingsResults(integrationSections), + actionResults: [] + }).map((result) => result.id) + + expect(rank('linear triage')).toEqual([]) + expect(rank('linear')).toEqual(['settings:linear', 'settings:integrations']) + // Why: a majority still counts, so one stray word cannot blank an otherwise good match. + expect(rank('linear integrations triage')).toEqual(['settings:integrations']) + }) + + it('drops verb-prefixed settings queries whose middle words match nothing', () => { + // Why: the verb-plus-settings-keyword rule reads only the head and tail of the query, so + // "new terminal browser settings" used to win on those two ends alone. + expect(top('new terminal sparkles glitter browser settings')).toBeUndefined() + expect(top('new terminal settings')).toBe('settings:terminal') + }) + + it('keeps out-of-order partial-word matches when every query word lands somewhere', () => { + expect(top('font term')).toBe('settings:terminal') + expect(top('font sparkles')).toBeUndefined() + }) + + it('ignores navigation filler words when measuring coverage', () => { + // Why: "open"/"go to" carry no intent, so they must not read as unmatched words + // and blank the band mid-query. + expect(top('open terminal settings')).toBe('settings:terminal') + expect(top('go to ssh settings')).toBe('settings:ssh') + expect(top('change the terminal font')).toBe('settings:terminal') + }) + + it('counts non-latin query words instead of discarding them', () => { + // Why: tokenizing on ASCII only let a localized query skip the coverage rule entirely. + expect(top('ssh 主机 设置')).toBeUndefined() + expect(top('ssh 设置')).toBeUndefined() + }) + it('does not match settings on one-character or description-only queries', () => { expect(top('t')).toBeUndefined() expect(top('cookie import')).toBeUndefined() @@ -316,6 +376,23 @@ function projectGroup(id: string, name: string, parentGroupId: string | null = n } describe('Cmd+J project and repo-group search', () => { + it('drops projects that only match the query through a generic alias word', () => { + // Why: every project carries the 'repo'/'project' aliases, so "repo triage" used to + // match all of them on that word alone — the same bug the middle band had. + const search = (query: string): string[] => + searchCmdJProjectResults({ + query, + projectGroups: [], + repos: [repo('repo-1', 'linear-sync'), repo('repo-2', 'billing')], + projects: [], + projectHostSetups: [] + }).map((result) => result.title) + + expect(search('repo triage')).toEqual([]) + expect(search('linear triage')).toEqual([]) + expect(search('linear repo')).toEqual(['linear-sync']) + }) + it('finds a Project Group by name', () => { const [result] = searchCmdJProjectResults({ query: 'infra', diff --git a/src/renderer/src/components/cmd-j/palette-results.ts b/src/renderer/src/components/cmd-j/palette-results.ts index 9246599843c..3b643c1fe8e 100644 --- a/src/renderer/src/components/cmd-j/palette-results.ts +++ b/src/renderer/src/components/cmd-j/palette-results.ts @@ -1,6 +1,11 @@ import type { SettingsNavIcon, SettingsNavSection } from '@/lib/settings-navigation-types' import type { CmdJQuickAction } from './quick-actions' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { + cmdJPaletteTokenScore, + normalizeCmdJPaletteQuery, + uniqueNormalizedCmdJPaletteKeywords +} from './palette-query-tokens' export type CmdJSettingsResult = { id: string @@ -53,40 +58,6 @@ export function isCmdJPaletteQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } -function normalizeQuery(value: string): string { - let normalized = '' - let pendingWhitespace = false - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (isCmdJPaletteWhitespace(code)) { - pendingWhitespace = normalized.length > 0 - continue - } - if (pendingWhitespace) { - normalized += ' ' - pendingWhitespace = false - } - normalized += value.charAt(index).toLowerCase() - } - return normalized -} - -function isCmdJPaletteWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - function keywordParts(section: SettingsNavSection): string[] { const baseId = section.id.startsWith('repo-') ? 'repo' : section.id const idWords = baseId.replace(/-/g, ' ') @@ -107,10 +78,6 @@ function targetEntryKeywordParts(entryTitle: string): string[] { return [entryTitle, `${entryTitle} settings`] } -function uniqueNormalized(values: readonly string[]): string[] { - return [...new Set(values.map(normalizeQuery).filter(Boolean))] -} - export function buildCmdJSettingsResults( sections: readonly SettingsNavSection[] ): CmdJSettingsResult[] { @@ -123,7 +90,7 @@ export function buildCmdJSettingsResults( icon: section.icon, sectionId: section.id, order, - configKeywords: uniqueNormalized(keywordParts(section)) + configKeywords: uniqueNormalizedCmdJPaletteKeywords(keywordParts(section)) } const targetedResults = section.searchEntries .filter((entry) => entry.targetSectionId) @@ -136,7 +103,7 @@ export function buildCmdJSettingsResults( sectionId: section.id, targetSectionId: entry.targetSectionId, order: order + (entryIndex + 1) / 100, - configKeywords: uniqueNormalized([ + configKeywords: uniqueNormalizedCmdJPaletteKeywords([ ...targetEntryKeywordParts(entry.title), ...(entry.cmdJKeywords ?? entry.keywords ?? []) ]) @@ -154,35 +121,6 @@ function startsOrIsStartedBy(query: string, keyword: string): boolean { return keyword.startsWith(query) || query.startsWith(keyword) } -function tokenize(value: string): string[] { - return normalizeQuery(value) - .split(/[^a-z0-9]+/) - .filter(Boolean) -} - -function tokenScore(query: string, values: readonly string[]): number { - const candidateTokens = values.flatMap(tokenize) - if (candidateTokens.length === 0) { - return 0 - } - - let score = 0 - for (const queryToken of tokenize(query)) { - let best = 0 - for (const candidateToken of candidateTokens) { - if (candidateToken === queryToken) { - best = Math.max(best, 3) - } else if (candidateToken.startsWith(queryToken)) { - best = Math.max(best, 2) - } else if (candidateToken.includes(queryToken)) { - best = Math.max(best, 1) - } - } - score += best - } - return score -} - function rankingForCandidate( query: string, candidate: CmdJMiddleResult, @@ -193,6 +131,17 @@ function rankingForCandidate( return null } + // Why: the shortcut rules below only inspect the head and tail of the query, so coverage has + // to gate all of them — otherwise "new terminal browser settings" still wins on rule 3. + const values = + candidate.kind === 'settings' + ? [candidate.title, ...candidate.configKeywords] + : [candidate.title, ...candidate.verbKeywords] + const score = cmdJPaletteTokenScore(query, values) + if (score === 0) { + return null + } + if (candidate.kind === 'action' && candidate.verbKeywords.some((keyword) => query === keyword)) { return { result: candidate, rule: 1, score: 0 } } @@ -227,12 +176,7 @@ function rankingForCandidate( return { result: candidate, rule: 5, score: 0 } } - const values = - candidate.kind === 'settings' - ? [candidate.title, ...candidate.configKeywords] - : [candidate.title, ...candidate.verbKeywords] - const score = tokenScore(query, values) - return score > 0 ? { result: candidate, rule: 6, score } : null + return { result: candidate, rule: 6, score } } function compareRanked(a: RankedResult, b: RankedResult): number { @@ -263,7 +207,7 @@ export function rankCmdJMiddleResults({ if (isCmdJPaletteQueryTooLarge(query)) { return [] } - const normalizedQuery = normalizeQuery(query) + const normalizedQuery = normalizeCmdJPaletteQuery(query) if (normalizedQuery.length < 2) { return [] } diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx index e148e132c96..ce7dc0936ab 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx @@ -34,18 +34,22 @@ export function AgentDashboardFilterChips({ projects, statuses, reviewLabel, + showAgentlessWorkspaces, onProjectToggle, onStatusToggle, onReviewToggle, + onAgentlessWorkspacesToggle, onClear }: { filters: DashboardFilters projects: FilterLabel[] statuses: FilterLabel[] reviewLabel: (id: DashboardReviewFilter) => string + showAgentlessWorkspaces: boolean onProjectToggle: (id: string) => void onStatusToggle: (id: string) => void onReviewToggle: (id: DashboardReviewFilter) => void + onAgentlessWorkspacesToggle: () => void onClear: () => void }): React.JSX.Element { return ( @@ -74,6 +78,15 @@ export function AgentDashboardFilterChips({ onRemove={() => onReviewToggle(id)} /> ))} + {showAgentlessWorkspaces ? ( + + ) : null} - ) : null} + ) : ( + + )} {query || activeCount > 0 ? ( @@ -215,6 +274,47 @@ export function AgentDashboardToolbar({ + {showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? ( + <> + + {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} + + onShowAgentlessWorkspacesChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {translate( + 'dashboardPopout.map.filters.agentlessWorkspaces', + 'Workspaces without agents' + )} + + + + + + ) : null} + {agentStates && agentStateCounts ? ( + <> + + {translate('dashboardPopout.map.filters.showStates', 'Agent states')} + + {AGENT_STATE_ROWS.map(({ state, dotState }) => ( + onAgentStateToggle?.(state)} + onSelect={(event) => event.preventDefault()} + > + + {agentStateLabel(state)} + + + ))} + + + ) : null} {translate('dashboardPopout.filters.project', 'Project')} @@ -285,9 +385,11 @@ export function AgentDashboardToolbar({ projects={projects} statuses={statuses} reviewLabel={reviewLabel} + showAgentlessWorkspaces={showAgentlessWorkspaces === true} onProjectToggle={toggleProject} onStatusToggle={toggleStatus} onReviewToggle={toggleReview} + onAgentlessWorkspacesToggle={() => onShowAgentlessWorkspacesChange?.(false)} onClear={clearFilters} /> ) : null} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index f9390570e30..58bb890d139 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -3,15 +3,20 @@ import '@testing-library/jest-dom/vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import type { DashboardCard, DashboardFilterOptions, - DashboardSnapshot + DashboardSnapshot, + DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { i18n } from '@/i18n/i18n' import { AgentKanbanBoard } from './AgentKanbanBoard' +const MAP_LOAD_TIMEOUT = { timeout: 5_000 } + // Stub the card and dialog so the board test stays free of xterm / Radix // machinery while still exercising the board-owned dialog wiring. vi.mock('./AgentKanbanCard', () => ({ @@ -54,6 +59,17 @@ vi.mock('./AgentTerminalDialog', () => ({ > + + {headerActions || onClose ? (
{headerActions} @@ -247,35 +357,63 @@ export function AgentKanbanBoard({
) : null} - -
- {/* Auto margins center the capped board and collapse during horizontal overflow. */} -
- {visibleBuckets.map((bucket) => ( - - ))} -
-
- + {view !== 'board' ? ( + + + + ) : ( + <> + +
+ {/* Auto margins center the capped board and collapse during horizontal overflow. */} +
+ {visibleBuckets.map((bucket) => ( + + ))} +
+
+ + )} + {view === 'board' ? ( + + ) : null} ) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index 24394cae866..5036478d94c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -159,7 +159,7 @@ describe('AgentKanbanCard', () => { expect(screen.queryByRole('img', { name: 'In review' })).not.toBeInTheDocument() }) - it('tints attention amber and done green, leaving every other state neutral', () => { + it('tints unseen Done green and keeps acknowledged Done neutral as Idle', () => { const { container: attention } = renderCard({ card: card({ bucket: 'attention', dotState: 'waiting' }), now: 2_000 @@ -168,14 +168,14 @@ describe('AgentKanbanCard', () => { cleanup() const { container: done } = renderCard({ - card: card({ bucket: 'idle', dotState: 'done' }), + card: card({ bucket: 'done', dotState: 'done', unseen: true }), now: 2_000 }) expect(done.firstElementChild?.className).toContain('border-emerald-500/40') cleanup() const { container: idle } = renderCard({ - card: card({ bucket: 'idle', dotState: 'idle' }), + card: card({ bucket: 'idle', dotState: 'done', unseen: false }), now: 2_000 }) const idleClassName = idle.firstElementChild?.className ?? '' diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index 556d5f563c1..4e6cffa14af 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -14,7 +14,10 @@ import { AgentStateDot } from '@/components/AgentStateDot' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { + dashboardCardDisplayState, + type DashboardCard +} from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { translate } from '@/i18n/i18n' @@ -200,7 +203,8 @@ export const AgentKanbanCard = memo( // "answer me", green for "finished, look at it". Everything else stays // neutral so the tint keeps meaning something. const needsYou = card.bucket === 'attention' - const isDone = card.dotState === 'done' + const displayState = dashboardCardDisplayState(card) + const isDone = displayState === 'done' // Why: the session's own name heads the card. Without one the worktree is // the best heading left — and then the footer drops it rather than say it // twice. @@ -240,7 +244,7 @@ export const AgentKanbanCard = memo( > {heading}
- {card.askSummary ? null : } + {card.askSummary ? null : } {card.lastUserMessage || card.lastAgentMessage ? ( diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx new file mode 100644 index 00000000000..483e9d1e9ed --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -0,0 +1,752 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMap } from './AgentMap' +import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode' +import type { AgentMapState } from './agent-map-filter' +import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout' + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + conversationName: 'Agent alpha', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +function renderMap( + cards: DashboardCard[], + { + onOpenTerminal = vi.fn(), + selectedPaneKey = null, + compact = false, + workspaceContextMenusEnabled = false, + enabledStates, + launchableAgentsByWorktreeId, + onSpawnAgent, + onSleepWorkspace + }: { + onOpenTerminal?: (card: DashboardCard) => void + selectedPaneKey?: string | null + compact?: boolean + workspaceContextMenusEnabled?: boolean + enabledStates?: ReadonlySet + launchableAgentsByWorktreeId?: Record + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void + } = {} +): ReturnType { + return render( + + ) +} + +describe('AgentMap', () => { + const originalUserAgent = navigator.userAgent + let boundsSpy: ReturnType + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + boundsSpy = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function getBounds(this: Element) { + if (this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement) { + return { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, + toJSON: () => ({}) + } + } + return { + x: 0, + y: 0, + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}) + } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() + boundsSpy.mockRestore() + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: originalUserAgent + }) + }) + + it('renders the amber marker only for unread agents', () => { + const finished = card({ + paneKey: 'done', + conversationName: 'Finished agent', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: true + }) + renderMap([card(), finished]) + + const workingNode = screen.getByRole('button', { name: /Agent alpha/ }) + const doneNode = screen.getByRole('button', { name: /Finished agent/ }) + expect(workingNode).toHaveClass('fleet-status-working') + expect(doneNode).toHaveClass('fleet-status-done') + expect(workingNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument() + expect(workingNode.querySelector('[data-agent-unread-marker]')).not.toBeInTheDocument() + expect(doneNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument() + const unreadMarker = doneNode.querySelector('[data-agent-unread-marker]') + expect(unreadMarker).toHaveClass('agent-map-agent-unread-mark') + // On the ring circumference at the top-left diagonal, where the halo breaks the ring. + const onRing = String(-AGENT_MAP_AGENT_RADIUS * Math.SQRT1_2) + expect(unreadMarker).toHaveAttribute('cx', onRing) + expect(unreadMarker).toHaveAttribute('cy', onRing) + expect(Number(unreadMarker?.getAttribute('r'))).toBeGreaterThanOrEqual( + AGENT_MAP_AGENT_RADIUS * 0.225 + ) + expect(unreadMarker).toHaveAttribute('vector-effect', 'none') + expect(doneNode).toHaveAccessibleName(/unread/) + }) + + it('lets attention override working on the worktree glow', () => { + const done = card({ + paneKey: 'done', + conversationName: 'Finished agent', + worktreeId: 'worktree-done', + worktreeName: 'Finished worktree', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000 + }) + const waiting = card({ + paneKey: 'waiting', + conversationName: 'Question agent', + bucket: 'attention', + dotState: 'waiting' + }) + const { container } = renderMap([card(), waiting, done]) + + const workingNode = screen.getByRole('button', { name: /Agent alpha/ }) + const waitingNode = screen.getByRole('button', { name: /Question agent/ }) + const doneNode = screen.getByRole('button', { name: /Finished agent/ }) + const workingRing = screen.getByRole('button', { + name: /Open Agent map worktree details/ + }) + const doneRing = screen.getByRole('button', { + name: /Open Finished worktree worktree details/ + }) + + expect(workingNode.querySelector('[data-agent-map-agent-working-glow]')).toBeInTheDocument() + expect(waitingNode.querySelector('[data-agent-map-agent-working-glow]')).not.toBeInTheDocument() + expect(doneNode.querySelector('[data-agent-map-agent-working-glow]')).not.toBeInTheDocument() + expect(workingRing).toHaveClass('is-waiting') + expect(workingRing).not.toHaveClass('is-working') + expect(doneRing).not.toHaveClass('is-working') + expect(container.querySelector('[data-agent-map-worktree-status-glow]')).toHaveAttribute( + 'data-worktree-active-status', + 'waiting' + ) + expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1) + }) + + it('keeps glow markup bounded for a large visible worktree', () => { + const cards = Array.from({ length: 120 }, (_, index) => { + const working = index % 2 === 0 + return card({ + paneKey: `pane-${index}`, + ptyId: `pty-${index}`, + tabId: `tab-${index}`, + leafId: `leaf-${index}`, + conversationName: `Agent ${index}`, + bucket: working ? 'working' : 'done', + dotState: working ? 'working' : 'done', + finishedAt: working ? null : NOW - 60_000 + }) + }) + const { container } = renderMap(cards, { selectedPaneKey: 'pane-0' }) + + expect(container.querySelectorAll('[data-agent-map-agent-working-glow]')).toHaveLength(60) + expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1) + expect(container.querySelectorAll('filter')).toHaveLength(0) + }) + + it('enlarges unread markers when the full fleet is zoomed out', () => { + const fleet = Array.from({ length: 72 }, (_, index) => + card({ + paneKey: `pane-${index}`, + ptyId: `pty-${index}`, + tabId: `tab-${index}`, + leafId: `leaf-${index}`, + worktreeId: `worktree-${index}`, + worktreeName: `Worktree ${index}`, + conversationName: `Agent ${index}`, + unseen: index === 0 + }) + ) + const { container } = renderMap(fleet) + const marker = container.querySelector('[data-agent-unread-marker]')! + const agentMark = + marker.parentElement!.querySelector('.agent-map-agent-mark')! + + expect(Number(marker.getAttribute('r'))).toBeGreaterThan( + Number(agentMark.getAttribute('r')) * 0.225 + ) + expect(marker).toHaveAttribute('vector-effect', 'none') + }) + + it('grows attention markers more gently than the inverse zoom while keeping a size floor', () => { + const mapScale = 0.2 + const markerScale = agentMapAttentionMarkerScale(mapScale) + + expect(markerScale).toBeGreaterThan(1) + expect(markerScale).toBeLessThan(1 / mapScale) + expect(AGENT_MAP_AGENT_RADIUS * 0.225 * markerScale * mapScale).toBeGreaterThanOrEqual(2.25) + }) + + it('shows worktree details and opens a running agent', () => { + const onOpenTerminal = vi.fn() + const running = card() + renderMap([running], { onOpenTerminal }) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + fireEvent.click(ring) + + expect(ring).toHaveClass('is-open') + expect(screen.queryByRole('button', { name: 'Focus ring' })).not.toBeInTheDocument() + // The popover names the project; the map itself draws it uppercased. + expect(screen.getByText('Orca')).toBeInTheDocument() + expect(screen.getByText('1 agent · 1 active · 0 done')).toBeInTheDocument() + expect(screen.getByText('Agent alpha')).toBeInTheDocument() + fireEvent.click(screen.getAllByRole('button', { name: /Agent alpha/ })[1]) + expect(onOpenTerminal).toHaveBeenCalledWith(running) + expect(ring).not.toHaveClass('is-open') + }) + + it('starts a new agent from the worktree details picker', () => { + const onSpawnAgent = vi.fn() + renderMap([card()], { + onSpawnAgent, + launchableAgentsByWorktreeId: { 'worktree-1': ['claude', 'codex'] } + }) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(screen.getByText('Start a new agent')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Codex/ })) + + // The raw worktree id, not the host-qualified map identity. + expect(onSpawnAgent).toHaveBeenCalledWith({ worktreeId: 'worktree-1', agent: 'codex' }) + }) + + it('explains an empty picker rather than offering nothing', () => { + renderMap([card()], { onSpawnAgent: vi.fn() }) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(screen.getByText('No enabled agents detected.')).toBeInTheDocument() + }) + + it('offers sleep and launch on right-click where the store menu is unavailable', async () => { + const onSleepWorkspace = vi.fn() + renderMap([card()], { + onSleepWorkspace, + onSpawnAgent: vi.fn(), + launchableAgentsByWorktreeId: { 'worktree-1': ['claude'] } + }) + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }), { + clientX: 10, + clientY: 10 + }) + + expect(await screen.findByText('Start a new agent')).toBeInTheDocument() + fireEvent.click(screen.getByText('Sleep')) + expect(onSleepWorkspace).toHaveBeenCalledWith({ worktreeId: 'worktree-1' }) + }) + + it('toggles worktree details from the keyboard', () => { + renderMap([card()]) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + + fireEvent.keyDown(ring, { key: 'Enter' }) + expect(ring).toHaveClass('is-open') + fireEvent.keyDown(ring, { key: 'Enter' }) + expect(ring).not.toHaveClass('is-open') + }) + + it('labels folder workspaces without presenting them as worktrees', () => { + renderMap([card({ workspaceKind: 'folder', worktreeName: 'Documentation' })]) + + expect( + screen.getByRole('button', { name: 'Open Documentation folder workspace details' }) + ).toBeInTheDocument() + }) + + it('connects spawned workers beneath their visible parent', () => { + const parent = card({ paneKey: 'parent', conversationName: 'Coordinator' }) + const child = card({ + paneKey: 'child', + parentPaneKey: 'parent', + conversationName: 'Worker' + }) + const nested = card({ + paneKey: 'nested', + parentPaneKey: 'child', + conversationName: 'Subagent' + }) + const orphan = card({ + paneKey: 'orphan', + parentPaneKey: 'filtered-parent', + conversationName: 'Orphaned worker' + }) + const { container } = renderMap([parent, child, nested, orphan]) + const workerLink = container.querySelector('[data-child-pane-key="child"]') + const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + + expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() + expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(2) + expect(workerLink).toHaveClass('agent-map-lineage-link') + expect(workerLink).not.toHaveClass('is-subagent') + expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') + expect(workerLink).toHaveAttribute('data-parent-pane-key', 'parent') + expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-subagent') + expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') + expect(subagentLink).toHaveAttribute('data-parent-pane-key', 'child') + }) + + it('connects spawned workers across worktree rings', () => { + const parent = card({ + paneKey: 'parent', + worktreeId: 'parent-worktree', + worktreeName: 'Parent workspace', + conversationName: 'Coordinator' + }) + const child = card({ + paneKey: 'child', + worktreeId: 'child-worktree', + worktreeName: 'Child workspace', + parentPaneKey: 'parent', + conversationName: 'Worker' + }) + const nested = card({ + paneKey: 'nested', + worktreeId: 'nested-worktree', + worktreeName: 'Nested workspace', + parentPaneKey: 'child', + conversationName: 'Subagent' + }) + const { container } = renderMap([parent, child, nested]) + const workerLink = container.querySelector('[data-child-pane-key="child"]') + const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + + expect(workerLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree') + expect(workerLink).not.toHaveClass('is-subagent') + expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') + expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree', 'is-subagent') + expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') + }) + + it('keeps lineage styling to one lightweight path per relationship at fleet scale', () => { + const cards = Array.from({ length: 240 }, (_, index) => + card({ + paneKey: `agent-${index}`, + parentPaneKey: index === 0 ? undefined : `agent-${index - 1}`, + conversationName: `Agent ${index}` + }) + ) + const { container } = renderMap(cards, { selectedPaneKey: 'agent-0' }) + + expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(239) + expect( + container.querySelectorAll('[data-agent-map-lineage-relation="orchestration"]') + ).toHaveLength(1) + expect(container.querySelectorAll('[data-agent-map-lineage-relation="subagent"]')).toHaveLength( + 238 + ) + expect(container.querySelectorAll('filter, animate, animateTransform')).toHaveLength(0) + }) + + it('connects visible child worktrees beneath their parent ring', () => { + const { container } = renderMap([ + card({ paneKey: 'parent', worktreeId: 'parent-worktree', worktreeName: 'Parent' }), + card({ + paneKey: 'child', + worktreeId: 'child-worktree', + worktreeName: 'Child', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'orphan', + worktreeId: 'orphan-worktree', + worktreeName: 'Orphan', + parentWorktreeId: 'filtered-parent' + }) + ]) + const links = container.querySelectorAll('[data-agent-map-worktree-lineage-link]') + + expect(links).toHaveLength(1) + expect(links[0]).toHaveAttribute('data-parent-worktree-id', 'parent-worktree') + expect(links[0]).toHaveAttribute('data-child-worktree-id', 'child-worktree') + }) + + it('opens the shared dashboard terminal dialog when an agent is clicked', () => { + const onOpenTerminal = vi.fn() + const agent = card() + renderMap([agent], { onOpenTerminal }) + + fireEvent.click(screen.getByRole('button', { name: /Agent alpha/ })) + expect(onOpenTerminal).toHaveBeenCalledWith(agent) + }) + + it('keeps a selected node visible while compacting around an adjacent terminal', () => { + const { container } = renderMap([card()], { selectedPaneKey: 'pane-1', compact: true }) + + const selectedNode = screen.getByRole('button', { name: /Agent alpha/ }) + expect(selectedNode).toHaveClass('is-selected') + expect(screen.getByRole('button', { name: /Agent alpha/ })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + const label = container.querySelector('.agent-map-worktree-label-group')! + expect(selectedNode.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + expect(label.querySelector('rect')).not.toBeInTheDocument() + expect(screen.getByText('270%')).toBeInTheDocument() + expect(screen.queryByText('Map filters')).not.toBeInTheDocument() + expect(screen.queryByRole('group', { name: 'Host filter' })).not.toBeInTheDocument() + }) + + it('eases the viewport into the selected agent', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const agent = card() + const view = renderMap([agent]) + + view.rerender( + + ) + act(() => frames.shift()?.(0)) + expect(screen.getByText('100%')).toBeInTheDocument() + act(() => frames.shift()?.(120)) + expect(screen.getByText('249%')).toBeInTheDocument() + act(() => frames.shift()?.(240)) + expect(screen.getByText('270%')).toBeInTheDocument() + }) + + it('keeps the selected node centered when topology changes around it', () => { + const selected = card() + const view = renderMap([selected], { selectedPaneKey: selected.paneKey }) + const svg = view.container.querySelector('.agent-map-canvas > svg')! + const selectedNode = (): SVGGElement => + view.container.querySelector('.agent-map-agent-node.is-selected')! + const nodeCenter = (): [number, number] => { + const match = selectedNode() + .getAttribute('transform') + ?.match(/translate\(([^ ]+) ([^)]+)\)/) + return [Number(match?.[1]), Number(match?.[2])] + } + const viewportCenter = (): [number, number] => { + const [x, y, width, height] = svg.getAttribute('viewBox')!.split(' ').map(Number) + return [x + width / 2, y + height / 2] + } + + const originalNodeCenter = nodeCenter() + view.rerender( + + ) + + expect(nodeCenter()).not.toEqual(originalNodeCenter) + expect(viewportCenter()[0]).toBeCloseTo(nodeCenter()[0]) + expect(viewportCenter()[1]).toBeCloseTo(nodeCenter()[1]) + }) + + it('increases map label scale when users zoom out', () => { + const { container } = renderMap([card()]) + const labelGroup = container.querySelector('.agent-map-worktree-label')?.parentElement + const initialScale = Number( + labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] + ) + + const readsBeforeZoom = boundsSpy.mock.calls.length + fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) + + const zoomedScale = Number( + labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] + ) + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) + expect(zoomedScale).toBeGreaterThan(initialScale) + }) + + it('avoids idle pointer layout reads and batches active viewport updates by frame', () => { + const frames: FrameRequestCallback[] = [] + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('requestAnimationFrame', requestFrame) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const { container } = renderMap([card()]) + const svg = container.querySelector('.agent-map-canvas > svg')! + Object.assign(svg, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn() + }) + + const readsBeforeHover = boundsSpy.mock.calls.length + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) + + fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + expect(svg.setPointerCapture).toHaveBeenCalledWith(1) + const readsAfterPointerDown = boundsSpy.mock.calls.length + const initialViewBox = svg.getAttribute('viewBox') + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) + + expect(boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) + expect(requestFrame).toHaveBeenCalledOnce() + expect(svg).toHaveAttribute('viewBox', initialViewBox) + + act(() => frames.shift()?.(0)) + expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox) + + requestFrame.mockClear() + const readsBeforeWheel = boundsSpy.mock.calls.length + const wheel = (): void => { + const event = new Event('wheel', { bubbles: true, cancelable: true }) + Object.defineProperties(event, { + deltaY: { value: -10 }, + clientX: { value: 100 }, + clientY: { value: 100 } + }) + fireEvent(svg, event) + expect(event.defaultPrevented).toBe(true) + } + wheel() + wheel() + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) + expect(requestFrame).toHaveBeenCalledOnce() + }) + + it('keeps active labels visible and progressively discloses quiet labels', () => { + const quiet = card({ + paneKey: 'done', + worktreeId: 'quiet-worktree', + worktreeName: 'Quiet result', + conversationName: 'Finished agent', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: false + }) + const { container } = renderMap([card(), quiet]) + const labels = [...container.querySelectorAll('.agent-map-worktree-label')] + const activeGroup = labels.find((label) => label.textContent === 'Agent map')?.parentElement + const quietGroup = labels.find((label) => label.textContent === 'Quiet result')?.parentElement + + expect(activeGroup).toHaveClass('is-visible') + expect(quietGroup).not.toHaveClass('is-visible') + }) + + it('shows unseen completions as Done and acknowledged completions as Idle by default', () => { + const seenResult = card({ + paneKey: 'seen', + conversationName: 'Seen result', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: false + }) + const newResult = card({ + paneKey: 'new', + conversationName: 'New result', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 2 * 60_000, + unseen: true + }) + renderMap([card(), seenResult, newResult]) + + expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /New result/ })).toHaveClass('fleet-status-done') + expect(screen.getByRole('button', { name: /Seen result/ })).toHaveClass('fleet-status-idle') + }) + + it('hides the states the toolbar filter has muted', () => { + const done = card({ + paneKey: 'done', + conversationName: 'Done agent', + unseen: true, + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000 + }) + renderMap([card(), done], { enabledStates: new Set(['done']) }) + + expect(screen.queryByRole('button', { name: /Agent alpha/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Done agent/ })).toBeInTheDocument() + }) + + it('preserves the viewport when filters temporarily empty the map', () => { + const agent = card() + const all = new Set(['attention', 'working', 'done', 'idle']) + const view = renderMap([agent], { enabledStates: all }) + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + const viewBox = view.container + .querySelector('.agent-map-canvas > svg')! + .getAttribute('viewBox') + + const onOpenTerminal = vi.fn() + view.rerender( + (['done'])} + /> + ) + expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument() + view.rerender( + + ) + + expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute( + 'viewBox', + viewBox + ) + }) + + it('preserves the viewport through an empty source snapshot', () => { + const agent = card() + const view = renderMap([agent]) + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + const viewBox = view.container + .querySelector('.agent-map-canvas > svg')! + .getAttribute('viewBox') + + view.rerender() + expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument() + view.rerender() + + expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute( + 'viewBox', + viewBox + ) + }) + + it('aggregates only acknowledged idle results without repacking topology', () => { + const results = Array.from({ length: 5 }, (_, index) => + card({ + paneKey: `done-${index}`, + conversationName: `Result ${index}`, + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: true + }) + ) + const view = renderMap(results) + const { container } = view + + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0) + view.rerender( + ({ ...result, unseen: false }))} + now={NOW} + onOpenTerminal={vi.fn()} + /> + ) + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(0) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(1) + + view.rerender( + ({ ...result, unseen: false }))} + now={NOW} + selectedPaneKey="done-0" + onOpenTerminal={vi.fn()} + /> + ) + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx new file mode 100644 index 00000000000..4e9c3c413ca --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { + DashboardCard, + DashboardCardHostKind, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs, + DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMapCanvas, type AgentMapCanvasHandle } from './AgentMapCanvas' +import { + filterAgentMapCards, + type AgentMapState, + type AgentMapHostFilter +} from './agent-map-filter' +import { updateAgentMapLayout, type AgentMapLayoutCache } from './agent-map-layout' +import './agent-map.css' + +type AgentMapProps = { + cards: DashboardCard[] + workspaces?: DashboardWorkspace[] + repoIconsByRepoId?: Record + now: number + className?: string + compact?: boolean + selectedPaneKey?: string | null + /** Owned by the board so the shared toolbar filter can drive it. Defaults to + * every state, i.e. the map shows whatever it is handed. */ + enabledStates?: ReadonlySet + launchableAgentsByWorktreeId?: Record + workspaceContextMenusEnabled?: boolean + onWorkspaceContextMenuOpenChange?: (open: boolean) => void + onOpenTerminal: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +const HOST_FILTERS: AgentMapHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote'] +const ALL_AGENT_STATES: ReadonlySet = new Set([ + 'attention', + 'working', + 'done', + 'idle' +]) +const EMPTY_WORKSPACES: DashboardWorkspace[] = [] + +function hostFilterLabel(filter: AgentMapHostFilter): string { + switch (filter) { + case 'all': + return translate('dashboardPopout.map.host.all', 'All hosts') + case 'local': + return translate('dashboardPopout.map.host.local', 'Local') + case 'ssh': + return translate('dashboardPopout.map.host.ssh', 'SSH') + case 'wsl': + return translate('dashboardPopout.map.host.wsl', 'WSL') + case 'remote': + return translate('dashboardPopout.map.host.remote', 'Remote') + } +} + +export function AgentMap({ + cards, + workspaces = EMPTY_WORKSPACES, + repoIconsByRepoId, + now, + className, + compact = false, + selectedPaneKey = null, + enabledStates = ALL_AGENT_STATES, + launchableAgentsByWorktreeId, + workspaceContextMenusEnabled = false, + onWorkspaceContextMenuOpenChange, + onOpenTerminal, + onSpawnAgent, + onSleepWorkspace +}: AgentMapProps): React.JSX.Element { + const canvasRef = useRef(null) + const layoutCacheRef = useRef(null) + const [hostFilter, setHostFilter] = useState('all') + const hostCounts = useMemo(() => { + const counts: Record = { + local: 0, + ssh: 0, + wsl: 0, + remote: 0 + } + for (const card of cards) { + counts[card.hostKind ?? 'local'] += 1 + } + for (const workspace of workspaces) { + counts[workspace.hostKind] += 1 + } + return counts + }, [cards, workspaces]) + const visibleCards = useMemo( + () => + filterAgentMapCards({ + cards, + enabledStates, + hostFilter + }), + [cards, enabledStates, hostFilter] + ) + const visibleWorkspaces = useMemo( + () => + hostFilter === 'all' + ? workspaces + : workspaces.filter((workspace) => workspace.hostKind === hostFilter), + [hostFilter, workspaces] + ) + const layoutResult = useMemo( + () => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now, visibleWorkspaces), + [visibleCards, visibleWorkspaces, now] + ) + useEffect(() => { + layoutCacheRef.current = layoutResult.cache + }, [layoutResult.cache]) + const layout = layoutResult.layout + + return ( +
+
+
+ + {translate( + 'dashboardPopout.map.filters.canvasSummary', + '{{shown}} of {{total}} agents shown', + { + shown: visibleCards.length, + total: cards.length + } + )} + + {!compact ? ( +
+ {HOST_FILTERS.filter((option) => option === 'all' || hostCounts[option] > 0).map( + (option) => ( + + ) + )} +
+ ) : null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx new file mode 100644 index 00000000000..7ec41a3d8b4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' + +const ringRender = vi.hoisted(() => vi.fn()) +vi.mock('./AgentMapWorktreeRingNode', () => ({ + AgentMapWorktreeRingNode: ({ worktree }: { worktree: { id: string } }) => { + ringRender(worktree.id) + return + } +})) + +import { AgentMap } from './AgentMap' + +const NOW = 2_000_000_000 +const CARD: DashboardCard = { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Measure map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Performance', + startedAt: NOW - 1_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false +} + +describe('AgentMapCanvas pointer performance', () => { + const frames: FrameRequestCallback[] = [] + + beforeEach(() => { + frames.length = 0 + ringRender.mockClear() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 560, + width: 800, + height: 560, + toJSON: () => ({}) + }) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('presents the map as a non-selectable panning surface', () => { + const { container } = render() + const svg = container.querySelector('.agent-map-canvas > svg')! + + expect(svg).toHaveClass('cursor-grab', 'touch-none', 'select-none', 'active:cursor-grabbing') + }) + + it('coalesces drag frames without rerendering worktree nodes', () => { + const { container } = render() + const svg = container.querySelector('.agent-map-canvas > svg')! + Object.assign(svg, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn() + }) + expect(ringRender).toHaveBeenCalledTimes(1) + + fireEvent.pointerDown(svg, { pointerId: 2, button: 2, clientX: 20, clientY: 20 }) + expect(svg.setPointerCapture).not.toHaveBeenCalled() + + fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 60, clientY: 20 }) + expect(frames).toHaveLength(1) + + act(() => frames.shift()?.(0)) + expect(ringRender).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx new file mode 100644 index 00000000000..33cd767cc6a --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -0,0 +1,415 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { translate } from '@/i18n/i18n' +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import { + AGENT_MAP_AGENT_RADIUS, + type AgentMapAgentNode, + type AgentMapProjectRing, + type AgentMapLayout +} from './agent-map-layout' +import { AgentMapScene } from './AgentMapScene' +import { AgentMapViewportControls } from './AgentMapViewportControls' +import { + agentMapAgents, + navigableAgentMapAgents, + nextDirectionalAgent +} from './agent-map-navigation' +import type { AgentMapViewport } from './agent-map-viewport-transition' +import { useAgentMapContextMenus } from './useAgentMapContextMenus' +import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' +import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' +import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' + +const MIN_ZOOM = 0.7 +const MAX_ZOOM = 24 +const AGENT_FOCUS_DURATION_MS = 240 +const AGENT_FOCUS_RADIUS_PX = 24 + +type Point = { x: number; y: number } + +export type AgentMapCanvasHandle = { + fit: () => void + focusProject: (project: AgentMapProjectRing) => void +} + +type AgentMapCanvasProps = { + layout: AgentMapLayout + repoIconsByRepoId?: Record + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgentsByWorktreeId?: Record + workspaceContextMenusEnabled?: boolean + onWorkspaceContextMenuOpenChange?: (open: boolean) => void + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)) +} + +function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number { + const aspect = width / Math.max(1, height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + return clamp( + Math.max( + 2, + (baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS) + ), + MIN_ZOOM, + MAX_ZOOM + ) +} + +export const AgentMapCanvas = forwardRef( + function AgentMapCanvas( + { + layout, + repoIconsByRepoId, + selectedPaneKey, + allowAggregation, + launchableAgentsByWorktreeId, + workspaceContextMenusEnabled = false, + onWorkspaceContextMenuOpenChange, + onSelectAgent, + onSpawnAgent, + onSleepWorkspace + }, + forwardedRef + ): React.JSX.Element { + const containerRef = useRef(null) + const svgRef = useRef(null) + const nodeRefs = useRef(new Map()) + const dragRef = useRef<{ + pointerId: number + point: Point + center: Point + worldPerPixelX: number + worldPerPixelY: number + } | null>(null) + const viewportFrameRef = useRef(null) + const pendingViewportRef = useRef(null) + const interactionBoundsRef = useRef(null) + const hasShownProjectsRef = useRef(layout.projects.length > 0) + const clearInteractionBounds = useCallback(() => { + interactionBoundsRef.current = null + }, []) + const size = useAgentMapCanvasSize(containerRef, clearInteractionBounds) + const [viewport, setViewport] = useState({ + center: { x: layout.width / 2, y: layout.height / 2 }, + zoom: 1 + }) + const prefersReducedMotion = usePrefersReducedMotion() + const viewportRef = useRef(viewport) + const { contextMenus, onOpenProjectContextMenu, onOpenWorkspaceContextMenu } = + useAgentMapContextMenus({ + enabled: workspaceContextMenusEnabled, + launchableAgentsByWorktreeId, + onOpenChange: onWorkspaceContextMenuOpenChange, + onSpawnAgent, + onSleepWorkspace + }) + const { center, zoom } = viewport + const agents = useMemo(() => agentMapAgents(layout), [layout]) + const navigableAgents = useMemo( + () => navigableAgentMapAgents(layout, zoom, allowAggregation, selectedPaneKey), + [allowAggregation, layout, selectedPaneKey, zoom] + ) + const hasProjects = layout.projects.length > 0 + const aspect = size.width / Math.max(1, size.height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + const baseHeight = baseWidth / aspect + const viewWidth = baseWidth / zoom + const viewHeight = baseHeight / zoom + const mapScale = size.width / viewWidth + const labelScale = Math.max(1, 1 / mapScale) + const viewBox = `${center.x - viewWidth / 2} ${center.y - viewHeight / 2} ${viewWidth} ${viewHeight}` + const focusZoom = agentFocusZoom(layout, size.width, size.height) + const resolveFocusZoom = useCallback((): number => { + const bounds = containerRef.current?.getBoundingClientRect() + return bounds && bounds.width > 0 && bounds.height > 0 + ? agentFocusZoom(layout, bounds.width, bounds.height) + : focusZoom + }, [focusZoom, layout]) + + const commitViewport = useCallback((next: AgentMapViewport): void => { + viewportRef.current = next + pendingViewportRef.current = null + interactionBoundsRef.current = null + setViewport(next) + }, []) + const { animate: animateViewport, stop: stopViewportTransition } = + useAgentMapViewportTransition({ + durationMs: AGENT_FOCUS_DURATION_MS, + reducedMotion: prefersReducedMotion, + onFrame: commitViewport + }) + useAgentMapSelectedFocus({ + agents, + selectedPaneKey, + viewportRef, + resolveFocusZoom, + animateViewport, + stopViewportTransition + }) + const applyViewport = useCallback( + (next: AgentMapViewport): void => { + stopViewportTransition() + commitViewport(next) + }, + [commitViewport, stopViewportTransition] + ) + const scheduleViewport = useCallback( + (next: AgentMapViewport): void => { + stopViewportTransition() + viewportRef.current = next + pendingViewportRef.current = next + if (viewportFrameRef.current !== null) { + return + } + viewportFrameRef.current = requestAnimationFrame(() => { + viewportFrameRef.current = null + interactionBoundsRef.current = null + const pending = pendingViewportRef.current + pendingViewportRef.current = null + if (pending) { + setViewport(pending) + } + }) + }, + [stopViewportTransition] + ) + const fit = useCallback((): void => { + applyViewport({ center: { x: layout.width / 2, y: layout.height / 2 }, zoom: 1 }) + }, [applyViewport, layout.height, layout.width]) + const focusProject = useCallback( + (project: AgentMapProjectRing): void => { + const projectWidth = project.radius * 2.5 + const projectHeight = project.radius * 2.5 + applyViewport({ + center: { x: project.x, y: project.y }, + zoom: clamp( + Math.min(baseWidth / projectWidth, baseHeight / projectHeight), + MIN_ZOOM, + MAX_ZOOM + ) + }) + }, + [applyViewport, baseHeight, baseWidth] + ) + useImperativeHandle(forwardedRef, () => ({ fit, focusProject }), [fit, focusProject]) + + useEffect(() => { + if (hasProjects && !hasShownProjectsRef.current) { + hasShownProjectsRef.current = true + fit() + } + }, [fit, hasProjects]) + + useEffect( + () => () => { + if (viewportFrameRef.current !== null) { + cancelAnimationFrame(viewportFrameRef.current) + } + }, + [] + ) + + const handleAgentKeyDown = useCallback( + (event: React.KeyboardEvent, agent: AgentMapAgentNode): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelectAgent(agent.card) + return + } + const direction = + event.key === 'ArrowLeft' + ? { x: -1, y: 0 } + : event.key === 'ArrowRight' + ? { x: 1, y: 0 } + : event.key === 'ArrowUp' + ? { x: 0, y: -1 } + : event.key === 'ArrowDown' + ? { x: 0, y: 1 } + : null + if (!direction) { + return + } + event.preventDefault() + const next = nextDirectionalAgent(agent, navigableAgents, direction) + nodeRefs.current.get(next?.card.paneKey ?? '')?.focus() + }, + [navigableAgents, onSelectAgent] + ) + + const zoomAt = useCallback( + (nextZoom: number, clientX?: number, clientY?: number): void => { + const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM) + if (clientX === undefined || clientY === undefined) { + applyViewport({ ...viewportRef.current, zoom: clampedZoom }) + return + } + const bounds = + interactionBoundsRef.current ?? svgRef.current?.getBoundingClientRect() ?? null + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + applyViewport({ ...viewportRef.current, zoom: clampedZoom }) + return + } + interactionBoundsRef.current = bounds + const current = viewportRef.current + const currentWidth = baseWidth / current.zoom + const currentHeight = baseHeight / current.zoom + const anchorX = + current.center.x - + currentWidth / 2 + + ((clientX - bounds.left) / bounds.width) * currentWidth + const anchorY = + current.center.y - + currentHeight / 2 + + ((clientY - bounds.top) / bounds.height) * currentHeight + const nextWidth = baseWidth / clampedZoom + const nextHeight = baseHeight / clampedZoom + const xRatio = (clientX - bounds.left) / bounds.width + const yRatio = (clientY - bounds.top) / bounds.height + scheduleViewport({ + center: { + x: anchorX - (xRatio - 0.5) * nextWidth, + y: anchorY - (yRatio - 0.5) * nextHeight + }, + zoom: clampedZoom + }) + }, + [applyViewport, baseHeight, baseWidth, scheduleViewport] + ) + + useEffect(() => { + if (!hasProjects) { + return + } + const svg = svgRef.current + if (!svg) { + return + } + const handleWheel = (event: WheelEvent): void => { + event.preventDefault() + zoomAt( + viewportRef.current.zoom * Math.exp(-event.deltaY * 0.0015), + event.clientX, + event.clientY + ) + } + svg.addEventListener('wheel', handleWheel, { passive: false }) + return () => svg.removeEventListener('wheel', handleWheel) + }, [hasProjects, zoomAt]) + + return ( +
+ {!hasProjects ? ( +
+ {translate('dashboardPopout.map.empty', 'No agents match the current filters.')} +
+ ) : ( + { + if (event.button !== 0) { + return + } + if ( + (event.target as Element).closest( + '[data-agent-map-agent], .agent-map-worktree-ring' + ) + ) { + return + } + const bounds = event.currentTarget.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) { + return + } + const current = viewportRef.current + dragRef.current = { + pointerId: event.pointerId, + point: { x: event.clientX, y: event.clientY }, + center: current.center, + worldPerPixelX: baseWidth / current.zoom / bounds.width, + worldPerPixelY: baseHeight / current.zoom / bounds.height + } + event.currentTarget.setPointerCapture(event.pointerId) + }} + onPointerMove={(event) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) { + return + } + scheduleViewport({ + center: { + x: drag.center.x - (event.clientX - drag.point.x) * drag.worldPerPixelX, + y: drag.center.y - (event.clientY - drag.point.y) * drag.worldPerPixelY + }, + zoom: viewportRef.current.zoom + }) + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + event.currentTarget.releasePointerCapture(event.pointerId) + } + }} + onPointerCancel={(event) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + } + }} + > + + + )} + + zoomAt(viewportRef.current.zoom * 1.25)} + onZoomOut={() => zoomAt(viewportRef.current.zoom / 1.25)} + /> + {contextMenus} +
+ ) + } +) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx new file mode 100644 index 00000000000..ba653e7cd59 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx @@ -0,0 +1,114 @@ +import { useEffect, useMemo, useRef } from 'react' +import { Plus } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { useAppStore } from '@/store' +import { getRepoHeaderCreateState } from '@/components/sidebar/repo-header-create-state' +import { translate } from '@/i18n/i18n' + +const FOLDER_PROJECT_PREFIX = 'folder-workspace:' + +export type AgentMapProjectContextMenuRequest = { + id: number + projectId: string + clientX: number + clientY: number +} + +type AgentMapProjectContextMenuProps = { + request: AgentMapProjectContextMenuRequest + onOpenChange?: (open: boolean) => void +} + +export function AgentMapProjectContextMenu({ + request, + onOpenChange +}: AgentMapProjectContextMenuProps): React.JSX.Element | null { + const triggerRef = useRef(null) + const repos = useAppStore((state) => state.repos) + const projectGroups = useAppStore((state) => state.projectGroups) + const target = useMemo(() => { + if (request.projectId.startsWith(FOLDER_PROJECT_PREFIX)) { + const groupId = request.projectId.slice(FOLDER_PROJECT_PREFIX.length) + const groups = projectGroups.filter((group) => group.id === groupId) + return groups.length === 1 ? { kind: 'folder' as const, group: groups[0] } : null + } + const owners = repos.filter((repo) => repo.id === request.projectId) + return owners.length === 1 ? { kind: 'repo' as const, repo: owners[0] } : null + }, [projectGroups, repos, request.projectId]) + const repo = target?.kind === 'repo' ? target.repo : null + const sshStatus = useAppStore((state) => + repo?.connectionId ? (state.sshConnectionStates.get(repo.connectionId)?.status ?? null) : null + ) + const openModal = useAppStore((state) => state.openModal) + + useEffect(() => { + if (!target) { + onOpenChange?.(false) + return + } + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + button: 2 + }) + ) + }, [onOpenChange, request, target]) + + if (!target) { + return null + } + const label = target.kind === 'repo' ? target.repo.displayName : target.group.name + const createState = + target.kind === 'repo' + ? getRepoHeaderCreateState({ repo: target.repo, label, sshStatus }) + : { + disabled: false, + tooltip: translate( + 'auto.components.sidebar.repo.header.create.state.62e71f2d5d', + 'Create workspace for {{value0}}', + { value0: label } + ), + ariaLabel: translate( + 'auto.components.sidebar.repo.header.create.state.62e71f2d5d', + 'Create workspace for {{value0}}', + { value0: label } + ) + } + + return ( +
+ + + + + + {label} + { + openModal( + 'new-workspace-composer', + target.kind === 'repo' + ? { initialRepoId: target.repo.id, telemetrySource: 'sidebar' } + : { initialProjectGroupId: target.group.id, telemetrySource: 'sidebar' } + ) + }} + > + + {createState.tooltip} + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx new file mode 100644 index 00000000000..ff81fdaca4f --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx @@ -0,0 +1,29 @@ +import { Suspense } from 'react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import type { AgentMapProjectContextMenuRequest } from './AgentMapProjectContextMenu' + +const AgentMapProjectContextMenu = lazyWithRetry( + () => + import('./AgentMapProjectContextMenu').then((module) => ({ + default: module.AgentMapProjectContextMenu + })), + { reloadKey: 'agent-map-project-context-menu' } +) + +type AgentMapProjectContextMenuLoaderProps = { + request: AgentMapProjectContextMenuRequest + onOpenChange?: (open: boolean) => void +} + +export function AgentMapProjectContextMenuLoader({ + request, + onOpenChange +}: AgentMapProjectContextMenuLoaderProps): React.JSX.Element { + return ( + + + + ) +} + +export type { AgentMapProjectContextMenuRequest } diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx new file mode 100644 index 00000000000..5cbf41b7abb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { AgentMapLayout } from './agent-map-layout' +import { AgentMapScene } from './AgentMapScene' + +const LAYOUT: AgentMapLayout = { + projects: [ + { + id: 'repo-1', + name: 'Orca', + x: 120, + y: 120, + radius: 96, + worktrees: [], + agentCount: 1 + } + ], + width: 240, + height: 240, + topologyKey: 'repo-1' +} + +describe('AgentMapScene project labels', () => { + it('renders the configured repository image next to its name', () => { + const { container } = render( + + + + ) + + const label = container.querySelector('.agent-map-project-label')! + expect(label).toHaveTextContent('ORCA') + expect(label).toHaveClass('agent-map-project-label') + expect(label.querySelector('.agent-map-project-name')).toHaveTextContent('ORCA') + expect(label.querySelector('img')).toHaveAttribute('src', 'data:image/png;base64,AAAA') + expect(label.firstElementChild?.querySelector('img')).toBeInTheDocument() + expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('x', '-48') + expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('width', '96') + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx new file mode 100644 index 00000000000..2719f4f7921 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -0,0 +1,243 @@ +import { memo, useCallback, useMemo, useState, type MutableRefObject } from 'react' +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { translate } from '@/i18n/i18n' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import type { + AgentMapAgentNode, + AgentMapLayout, + AgentMapProjectRing, + AgentMapWorktreeRing +} from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' +import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel' +import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' + +type AgentMapSceneProps = { + layout: AgentMapLayout + repoIconsByRepoId?: Record + zoom: number + labelScale: number + mapScale: number + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgentsByWorktreeId?: Record + nodeRefs: MutableRefObject> + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onOpenProjectContextMenu?: ( + event: React.MouseEvent, + project: AgentMapProjectRing + ) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void + onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void +} + +function worktreeLineagePath(parent: AgentMapWorktreeRing, child: AgentMapWorktreeRing): string { + const startY = parent.y + parent.radius + const endY = child.y - child.radius + const branchY = (startY + endY) / 2 + return `M ${parent.x} ${startY} C ${parent.x} ${branchY} ${child.x} ${branchY} ${child.x} ${endY}` +} + +type VisibleAgentLocation = { + agent: AgentMapAgentNode + worktreeId: string +} + +function agentLineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { + const dx = child.x - parent.x + const dy = child.y - parent.y + const distance = Math.hypot(dx, dy) + if (distance === 0) { + return `M ${parent.x} ${parent.y}` + } + const unitX = dx / distance + const unitY = dy / distance + return `M ${parent.x + unitX * parent.radius} ${parent.y + unitY * parent.radius} L ${child.x - unitX * child.radius} ${child.y - unitY * child.radius}` +} + +/** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */ +export const AgentMapScene = memo(function AgentMapScene({ + layout, + repoIconsByRepoId, + zoom, + labelScale, + mapScale, + selectedPaneKey, + allowAggregation, + launchableAgentsByWorktreeId, + nodeRefs, + onSelectAgent, + onSpawnAgent, + onOpenProjectContextMenu, + onOpenWorkspaceContextMenu, + onAgentKeyDown +}: AgentMapSceneProps): React.JSX.Element { + const [activeWorktreeId, setActiveWorktreeId] = useState(null) + const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => { + setActiveWorktreeId((current) => + active ? worktreeId : current === worktreeId ? null : current + ) + }, []) + const visibleLabels = useMemo( + () => selectVisibleAgentMapLabels(layout, labelScale, mapScale), + [labelScale, layout, mapScale] + ) + const visibleAgentsByPaneKey = useMemo(() => { + const agents = new Map() + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey) + if (!selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)) { + continue + } + for (const agent of worktree.agents) { + agents.set(agent.card.paneKey, { agent, worktreeId: worktree.id }) + } + } + } + return agents + }, [allowAggregation, layout, selectedPaneKey, zoom]) + return ( + <> + {layout.projects.map((project) => { + const worktreesById = new Map(project.worktrees.map((worktree) => [worktree.id, worktree])) + const projectLabelHalfWidth = project.radius * mapScale + const projectCountText = translate( + 'dashboardPopout.map.projectCount', + '{{agents}} agents · {{workspaces}} workspaces', + { agents: project.agentCount, workspaces: project.worktrees.length } + ).toUpperCase() + const crossWorktreeLineage = project.worktrees.flatMap((worktree) => + worktree.agents.flatMap((child) => { + const parent = child.card.parentPaneKey + ? visibleAgentsByPaneKey.get(child.card.parentPaneKey) + : undefined + const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey) + return parent && childLocation && parent.worktreeId !== childLocation.worktreeId + ? [{ parent: parent.agent, child }] + : [] + }) + ) + return ( + + { + event.preventDefault() + event.stopPropagation() + onOpenProjectContextMenu(event, project) + } + : undefined + } + /> + + {project.worktrees.map((child) => { + const parent = child.parentId ? worktreesById.get(child.parentId) : undefined + return !parent || child.y <= parent.y ? null : ( + + ) + })} + + + {crossWorktreeLineage.map(({ parent, child }) => { + const relation = parent.card.parentPaneKey ? 'subagent' : 'orchestration' + return ( + + ) + })} + + {project.worktrees.map((worktree) => ( + + ))} + + {project.worktrees.map((worktree) => ( + + ))} + + + +
+ + + {project.name.toUpperCase()} + +
+
+ {visibleLabels.projectCountIds.has(project.id) ? ( + + {projectCountText} + + ) : null} +
+
+ ) + })} + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx new file mode 100644 index 00000000000..e2208e0be0d --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef } from 'react' +import { Moon, Plus } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { translate } from '@/i18n/i18n' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import type { + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' + +export type AgentMapSnapshotWorkspaceMenuRequest = { + id: number + worktreeId: string + worktreeName: string + launchableAgents: readonly TuiAgent[] + clientX: number + clientY: number +} + +type AgentMapSnapshotWorkspaceMenuProps = { + request: AgentMapSnapshotWorkspaceMenuRequest + onOpenChange?: (open: boolean) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +/** + * The workspace right-click menu for surfaces without the app store — the + * pop-out window. Its actions are relayed to the main renderer, so it offers + * only what a snapshot can describe, not the full sidebar menu. + */ +export function AgentMapSnapshotWorkspaceMenu({ + request, + onOpenChange, + onSpawnAgent, + onSleepWorkspace +}: AgentMapSnapshotWorkspaceMenuProps): React.JSX.Element { + const triggerRef = useRef(null) + useEffect(() => { + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + button: 2 + }) + ) + }, [request]) + + return ( +
+ + + + + + {request.worktreeName} + {onSpawnAgent ? ( + + + + {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} + + + {request.launchableAgents.map((agent) => ( + onSpawnAgent({ worktreeId: request.worktreeId, agent })} + > + + {getAgentLabel(agent)} + + ))} + + + ) : null} + {onSpawnAgent && onSleepWorkspace ? : null} + {onSleepWorkspace ? ( + onSleepWorkspace({ worktreeId: request.worktreeId })}> + + {translate('dashboardPopout.map.sleepWorkspace', 'Sleep')} + + ) : null} + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx new file mode 100644 index 00000000000..6e035dca692 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx @@ -0,0 +1,47 @@ +import { Focus, Minus, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +type AgentMapViewportControlsProps = { + zoom: number + onFit: () => void + onZoomIn: () => void + onZoomOut: () => void +} + +export function AgentMapViewportControls({ + zoom, + onFit, + onZoomIn, + onZoomOut +}: AgentMapViewportControlsProps): React.JSX.Element { + return ( +
+ + + {Math.round(zoom * 100)}% + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts new file mode 100644 index 00000000000..7275b3d274e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function source(file: string): string { + return readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout', file), + 'utf8' + ) +} + +describe('Agent Map workspace menu performance boundary', () => { + it('loads store-backed workspace actions only after a ring context request', () => { + const loader = source('AgentMapWorkspaceContextMenuLoader.tsx') + const menu = source('AgentMapWorkspaceContextMenu.tsx') + + expect(loader).toMatch(/import\('\.\/AgentMapWorkspaceContextMenu'\)/) + expect(loader).not.toMatch( + /import\s+\{\s*AgentMapWorkspaceContextMenu\s*\}\s+from\s+['"]\.\/AgentMapWorkspaceContextMenu['"]/ + ) + expect(menu).toMatch(/import\('@\/components\/sidebar\/WorktreeContextMenu'\)/) + expect(menu).not.toMatch( + /import\s+WorktreeContextMenu\s+from\s+['"]@\/components\/sidebar\/WorktreeContextMenu['"]/ + ) + }) + + it('loads store-backed project actions only after a project context request', () => { + const loader = source('AgentMapProjectContextMenuLoader.tsx') + + expect(loader).toMatch(/import\('\.\/AgentMapProjectContextMenu'\)/) + expect(loader).not.toMatch( + /import\s+\{\s*AgentMapProjectContextMenu\s*\}\s+from\s+['"]\.\/AgentMapProjectContextMenu['"]/ + ) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx new file mode 100644 index 00000000000..057a593da7e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx @@ -0,0 +1,375 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { AgentMap } from './AgentMap' +import * as StoreSelectors from '@/store/selectors' + +const NOW = 2_000_000_000 +const EXECUTION_HOST_ID = 'runtime:env-1' as const +const initialState = useAppStore.getState() + +const repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Orca', + badgeColor: '#000000', + addedAt: NOW, + kind: 'git', + executionHostId: EXECUTION_HOST_ID +} satisfies Repo + +const worktree = { + id: 'worktree-1', + repoId: repo.id, + path: '/repo/worktrees/map', + displayName: 'Agent map', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + branch: 'refs/heads/agent-map', + head: 'abc123', + isBare: false, + isMainWorktree: false, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + hostId: EXECUTION_HOST_ID +} satisfies Worktree + +const collidingLocalWorktree = { + ...worktree, + path: '/local/repo/worktrees/map', + displayName: 'Local agent map', + hostId: 'local' +} satisfies Worktree + +const parentWorktree = { + ...worktree, + id: 'worktree-parent', + path: '/repo/worktrees/parent', + displayName: 'Parent worktree', + branch: 'refs/heads/parent' +} satisfies Worktree + +const folderProjectGroup = { + id: 'group-1', + name: 'Documentation', + parentPath: '/docs', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW +} satisfies ProjectGroup + +const card: DashboardCard = { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: repo.id, + worktreeId: worktree.id, + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: repo.displayName, + worktreeName: worktree.displayName, + startedAt: NOW - 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + workspaceKind: 'worktree' +} + +describe('Agent Map workspace context menu', () => { + beforeEach(() => { + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree, parentWorktree] }, + detectedWorktreesByRepo: {}, + projectGroups: [], + workspaceStatuses: [{ id: 'todo', label: 'Todo' }] + }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + vi.restoreAllMocks() + }) + + it('opens the shared sidebar workspace actions from a worktree ring', async () => { + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }), { + clientX: 120, + clientY: 140 + }) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + expect(screen.getByText('Update')).toBeInTheDocument() + expect(screen.getByText('Move to Status')).toBeInTheDocument() + expect(screen.getByText('Open in')).toBeInTheDocument() + expect(screen.getByText('Copy Path')).toBeInTheDocument() + expect(screen.getByText('Pin')).toBeInTheDocument() + expect(screen.getByText('Mark Unread')).toBeInTheDocument() + expect(screen.getByText('Sleep')).toBeInTheDocument() + expect(screen.getByText('Delete')).toBeInTheDocument() + expect(useWorktreeById).toHaveBeenCalledWith(worktree.id, EXECUTION_HOST_ID) + }) + + it('deduplicates the same host owner across known and detected worktrees', async () => { + useAppStore.setState({ + detectedWorktreesByRepo: { + [repo.id]: { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: [ + { ...worktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } + ] + } + } + }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + }) + + it('uses explicit SSH ownership instead of the paired hub repo host', async () => { + const sshHostId = 'ssh:provider-1' as const + const sshWorktree = { ...worktree, hostId: sshHostId } + useAppStore.setState({ worktreesByRepo: { [repo.id]: [sshWorktree] } }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + }) + + it('fails closed when bare-ID actions would span multiple execution hosts', async () => { + useAppStore.setState({ + worktreesByRepo: { [repo.id]: [collidingLocalWorktree, worktree] } + }) + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + await waitFor(() => + expect(useWorktreeById).toHaveBeenCalledWith(worktree.id, EXECUTION_HOST_ID) + ) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + + act(() => { + useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } }) + }) + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + }) + + it('clears a workspace request whose target disappeared', async () => { + useAppStore.setState({ worktreesByRepo: {} }) + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + await waitFor(() => expect(useWorktreeById).toHaveBeenCalled()) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + act(() => { + useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } }) + }) + + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + }) + + it('releases the store-backed workspace menu after an ordinary close', async () => { + const getKnownWorktreeById = vi.fn(useAppStore.getState().getKnownWorktreeById) + useAppStore.setState({ getKnownWorktreeById }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => expect(screen.queryByText('Workspace')).not.toBeInTheDocument()) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + + getKnownWorktreeById.mockClear() + act(() => { + useAppStore.setState({ agentStatusEpoch: useAppStore.getState().agentStatusEpoch + 1 }) + }) + expect(getKnownWorktreeById).not.toHaveBeenCalled() + }) + + it('keeps shared-menu follow-up overlays mounted through their lifecycle', async () => { + render( + + {}} + /> + + ) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + + fireEvent.contextMenu(ring) + const createGroup = await screen.findByText('New group from project', {}, { timeout: 5_000 }) + fireEvent.pointerDown(createGroup, { button: 0 }) + fireEvent.click(createGroup) + expect(await screen.findByRole('dialog', { name: 'New Project Group' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'New Project Group' })).not.toBeInTheDocument() + ) + + fireEvent.contextMenu(ring) + const setParent = await screen.findByText('Set Parent Worktree...', {}, { timeout: 5_000 }) + fireEvent.pointerDown(setParent, { button: 0 }) + fireEvent.click(setParent) + // Candidate rows are virtualized and measure 0 in happy-dom; the mounted + // search input is the picker's lifecycle signal. + expect(await screen.findByPlaceholderText('Search worktrees...')).toBeInTheDocument() + }) + + it('opens the existing worktree composer from a project ring', async () => { + const { container } = render( + + {}} /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!, { + clientX: 100, + clientY: 110 + }) + fireEvent.click(await screen.findByText('Create new worktree for Orca', {}, { timeout: 5_000 })) + + expect(useAppStore.getState().activeModal).toBe('new-workspace-composer') + expect(useAppStore.getState().modalData).toEqual({ + initialRepoId: repo.id, + telemetrySource: 'sidebar' + }) + }) + + it('opens the folder-workspace composer from a synthetic project ring', async () => { + useAppStore.setState({ projectGroups: [folderProjectGroup] }) + const folderCard = { + ...card, + repoId: `folder-workspace:${folderProjectGroup.id}`, + repoName: folderProjectGroup.name, + worktreeId: 'folder:folder-1', + worktreeName: 'Docs', + workspaceKind: 'folder' as const + } + const { container } = render( + + {}} + /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!) + fireEvent.click( + await screen.findByText('Create workspace for Documentation', {}, { timeout: 5_000 }) + ) + + expect(useAppStore.getState().modalData).toEqual({ + initialProjectGroupId: folderProjectGroup.id, + telemetrySource: 'sidebar' + }) + }) + + it('clears an ambiguous project request instead of choosing a repo host', async () => { + useAppStore.setState({ + repos: [repo, { ...repo, path: '/local/repo', executionHostId: 'local' }] + }) + const { container } = render( + + {}} /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + expect(screen.queryByText('Create new worktree for Orca')).not.toBeInTheDocument() + + act(() => { + useAppStore.setState({ repos: [repo] }) + }) + expect(screen.queryByText('Create new worktree for Orca')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx new file mode 100644 index 00000000000..1ac20df44d2 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx @@ -0,0 +1,167 @@ +import { Suspense, useEffect, useMemo, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { useAppStore } from '@/store' +import { useWorktreeById } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +const WorktreeContextMenu = lazyWithRetry( + () => import('@/components/sidebar/WorktreeContextMenu'), + { reloadKey: 'agent-map-worktree-context-menu' } +) + +export type AgentMapWorkspaceContextMenuRequest = { + id: number + worktreeId: string + executionHostId?: ExecutionHostId + clientX: number + clientY: number + altKey: boolean +} + +type AgentMapWorkspaceContextMenuProps = { + request: AgentMapWorkspaceContextMenuRequest | null + onOpenChange?: (open: boolean) => void + onLifecycleComplete?: () => void +} + +function countWorkspaceOwners( + worktreeId: string | null, + state: Pick< + AppState, + 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'repos' + > +): number { + if (!worktreeId) { + return 0 + } + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return new Set( + state.folderWorkspaces + .filter((workspace) => workspace.id === scope.folderWorkspaceId) + .map( + (workspace) => + normalizeExecutionHostId(workspace.executionHostId) ?? + (workspace.connectionId ? toSshExecutionHostId(workspace.connectionId) : 'local') + ) + ).size + } + const repoOwnerIdsByRepoId = new Map>() + for (const repo of state.repos) { + const ownerId = getRepoExecutionHostId(repo) + const owners = repoOwnerIdsByRepoId.get(repo.id) + if (owners) { + owners.add(ownerId) + } else { + repoOwnerIdsByRepoId.set(repo.id, new Set([ownerId])) + } + } + const ownerIds = new Set() + const addOwner = (worktree: { repoId: string; hostId?: ExecutionHostId }): void => { + const directOwner = normalizeExecutionHostId(worktree.hostId) + if (directOwner) { + ownerIds.add(directOwner) + return + } + const repoOwnerIds = repoOwnerIdsByRepoId.get(worktree.repoId) + if (!repoOwnerIds) { + ownerIds.add('local') + return + } + for (const ownerId of repoOwnerIds) { + ownerIds.add(ownerId) + } + } + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + if (worktree.id === worktreeId) { + addOwner(worktree) + } + } + } + for (const result of Object.values(state.detectedWorktreesByRepo)) { + for (const worktree of result.worktrees) { + if (worktree.id === worktreeId) { + addOwner(worktree) + } + } + } + return ownerIds.size +} + +function ContextMenuTrigger({ + request +}: { + request: AgentMapWorkspaceContextMenuRequest +}): React.JSX.Element { + const triggerRef = useRef(null) + useEffect(() => { + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + altKey: request.altKey, + button: 2 + }) + ) + }, [request]) + return +} + +export function AgentMapWorkspaceContextMenu({ + request, + onOpenChange, + onLifecycleComplete +}: AgentMapWorkspaceContextMenuProps): React.JSX.Element | null { + const { worktreesByRepo, detectedWorktreesByRepo, folderWorkspaces, repos } = useAppStore( + useShallow((state) => ({ + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + repos: state.repos + })) + ) + const worktree = useWorktreeById(request?.worktreeId ?? null, request?.executionHostId) + const ownerCount = useMemo( + () => + countWorkspaceOwners(request?.worktreeId ?? null, { + worktreesByRepo, + detectedWorktreesByRepo, + folderWorkspaces, + repos + }), + [detectedWorktreesByRepo, folderWorkspaces, repos, request?.worktreeId, worktreesByRepo] + ) + const unavailable = request !== null && (!worktree || ownerCount !== 1) + useEffect(() => { + if (unavailable) { + onLifecycleComplete?.() + } + }, [onLifecycleComplete, unavailable]) + if (!request || unavailable || !worktree) { + return null + } + return ( +
+ + + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx new file mode 100644 index 00000000000..8ece35ad2ef --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx @@ -0,0 +1,35 @@ +import { Suspense } from 'react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import type { AgentMapWorkspaceContextMenuRequest } from './AgentMapWorkspaceContextMenu' + +const AgentMapWorkspaceContextMenu = lazyWithRetry( + () => + import('./AgentMapWorkspaceContextMenu').then((module) => ({ + default: module.AgentMapWorkspaceContextMenu + })), + { reloadKey: 'agent-map-workspace-context-menu' } +) + +type AgentMapWorkspaceContextMenuLoaderProps = { + request: AgentMapWorkspaceContextMenuRequest + onOpenChange?: (open: boolean) => void + onLifecycleComplete?: () => void +} + +export function AgentMapWorkspaceContextMenuLoader({ + request, + onOpenChange, + onLifecycleComplete +}: AgentMapWorkspaceContextMenuLoaderProps): React.JSX.Element { + return ( + + + + ) +} + +export type { AgentMapWorkspaceContextMenuRequest } diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx new file mode 100644 index 00000000000..76398b29daf --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx @@ -0,0 +1,39 @@ +import { memo } from 'react' +import { translate } from '@/i18n/i18n' +import type { AgentMapWorktreeRing } from './agent-map-layout' + +type AgentMapWorktreeLabelProps = { + worktree: AgentMapWorktreeRing + visible: boolean + active: boolean + labelScale: number + mapScale: number +} + +export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({ + worktree, + visible, + active, + labelScale, + mapScale +}: AgentMapWorktreeLabelProps): React.JSX.Element { + const showCount = visible && (active || worktree.radius * mapScale >= 80) + const agentCountText = translate( + 'dashboardPopout.map.agentCount', + worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents', + { count: worktree.agents.length } + ) + return ( + + + {worktree.name} + + + {agentCountText} + + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx new file mode 100644 index 00000000000..9102b5e51c0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -0,0 +1,370 @@ +import { memo, useState, type MutableRefObject } from 'react' +import { Plus } from 'lucide-react' +import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { agentTypeToIconAgent } from '@/lib/agent-status' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import type { + AgentMapAgentNode, + AgentMapProjectRing, + AgentMapWorktreeRing +} from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status' + +type AgentMapWorktreeRingNodeProps = { + project: AgentMapProjectRing + worktree: AgentMapWorktreeRing + zoom: number + mapScale: number + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgents?: readonly TuiAgent[] + nodeRefs: MutableRefObject> + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void + onLabelActiveChange: (worktreeId: string, active: boolean) => void + onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void +} + +function formatDuration(minutes: number): string { + if (minutes < 1) { + return translate('dashboardPopout.card.time.justNow', 'just now') + } + if (minutes < 60) { + return translate('dashboardPopout.card.time.minutes', '{{count}}m', { + count: Math.floor(minutes) + }) + } + return translate('dashboardPopout.card.time.hours', '{{count}}h', { + count: Math.floor(minutes / 60) + }) +} + +function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { + const startY = parent.y + parent.radius + const endY = child.y - child.radius + const branchY = (startY + endY) / 2 + return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}` +} + +function agentName(card: DashboardCard): string { + return card.conversationName ?? (card.task.trim() || card.agentType) +} + +export function agentMapAttentionMarkerScale(mapScale: number): number { + const inverseScale = 1 / Math.max(mapScale, 0.001) + return Math.max(1, inverseScale ** 0.72, inverseScale * 0.5) +} + +function WorktreeDetails({ + project, + worktree, + launchableAgents, + onSelectAgent, + onSpawnAgent, + onDone +}: Pick< + AgentMapWorktreeRingNodeProps, + 'project' | 'worktree' | 'launchableAgents' | 'onSelectAgent' | 'onSpawnAgent' +> & { + onDone: () => void +}): React.JSX.Element { + const activeCount = + worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting + return ( + +
+ {project.name} + {worktree.name} + + {translate( + 'dashboardPopout.map.worktreeSummary', + '{{total}} agents · {{active}} active · {{done}} done', + { + count: worktree.agents.length, + defaultValue_one: '{{total}} agent · {{active}} active · {{done}} done', + defaultValue_other: '{{total}} agents · {{active}} active · {{done}} done', + total: worktree.agents.length, + active: activeCount, + done: worktree.statusCounts.done + } + )} + +
+
+

+ {translate('dashboardPopout.map.runningAgents', 'Agents')} +

+
+ {worktree.agents.length === 0 ? ( +

+ {translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')} +

+ ) : ( + worktree.agents.map((agent) => ( + + )) + )} +
+
+ {onSpawnAgent ? ( +
+

+ {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} +

+ {launchableAgents && launchableAgents.length > 0 ? ( +
+ {launchableAgents.map((agent) => ( + + ))} +
+ ) : ( +

+ {translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')} +

+ )} +
+ ) : null} +
+ ) +} + +export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ + project, + worktree, + zoom, + mapScale, + selectedPaneKey, + allowAggregation, + launchableAgents, + nodeRefs, + onSelectAgent, + onSpawnAgent, + onOpenWorkspaceContextMenu, + onLabelActiveChange, + onAgentKeyDown +}: AgentMapWorktreeRingNodeProps): React.JSX.Element { + const [detailsOpen, setDetailsOpen] = useState(false) + const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey) + const activeStatus = agentMapWorktreeActiveStatus(worktree.statusCounts) + const aggregate = !selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) + const agentsByPaneKey = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + + return ( + + onLabelActiveChange(worktree.id, true)} + onPointerLeave={() => onLabelActiveChange(worktree.id, false)} + onFocus={() => onLabelActiveChange(worktree.id, true)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + onLabelActiveChange(worktree.id, false) + } + }} + > + {activeStatus ? ( + + setDetailsOpen(false)} + /> + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 6610efe2605..7022d7e5731 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -2,27 +2,31 @@ import '@testing-library/jest-dom/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { DashboardCard, DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import { AgentTerminalDialog } from './AgentTerminalDialog' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { AgentTerminalDialog, AgentTerminalPanel } from './AgentTerminalDialog' // Stub the preview so the assertion is on the props the dialog hands it, with // no xterm / IPC machinery in the way. vi.mock('./AgentTerminalPreview', () => ({ AgentTerminalPreview: ({ ptyId, - terminalInput + terminalInput, + className }: { ptyId: string terminalInput?: DashboardCardTerminalInput | null + className?: string }) => (
) })) @@ -32,6 +36,7 @@ const TERMINAL_INPUT: DashboardCardTerminalInput = { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } @@ -85,4 +90,81 @@ describe('AgentTerminalDialog', () => { expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null') }) + + it('labels acknowledged completions idle without review or pin controls', () => { + render( + {}} + onReveal={() => {}} + /> + ) + + expect(screen.getByText(/Claude · Idle/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Keep visible' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Mark reviewed' })).not.toBeInTheDocument() + expect(screen.getByTestId('preview')).toHaveAttribute('data-pty-id', 'pty-1') + }) + + it('labels unseen completions done', () => { + render( + {}} + onReveal={() => {}} + /> + ) + + expect(screen.getByText(/Claude · Done/)).toBeInTheDocument() + }) + + it('preserves the execution host when revealing a colliding worktree ID', () => { + const onReveal = vi.fn() + render( + {}} + onReveal={onReveal} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) + expect(onReveal).toHaveBeenCalledWith({ + repoId: 'r1', + worktreeId: 'w1', + executionHostId: 'runtime:env-1', + tabId: 'tab1', + leafId: 'leaf1' + }) + }) + + it('reuses the terminal surface as a non-modal adjacent panel', () => { + render( {}} onReveal={() => {}} />) + + expect(screen.getByRole('dialog', { name: 'wt' })).toHaveAttribute('data-state', 'open') + expect(screen.getByTestId('preview')).toHaveClass('min-h-0', 'flex-1') + expect(document.querySelector('[data-slot="dialog-overlay"]')).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'wt' })).toBeInTheDocument() + }) + + it('lets a nested Radix layer consume Escape before closing the panel', () => { + const onOpenChange = vi.fn() + render( + <> + {}} /> + + Details + Worktree details + + + ) + + fireEvent.keyDown(screen.getByText('Worktree details'), { key: 'Escape' }) + + expect(screen.queryByText('Worktree details')).not.toBeInTheDocument() + expect(onOpenChange).not.toHaveBeenCalled() + + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index ead43090820..a5eea33adc3 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -1,21 +1,21 @@ -import { useCallback } from 'react' +import { useEffect, useId } from 'react' import { SquareArrowOutUpRight, XIcon } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { agentStateLabel } from '@/components/AgentStateDot' -import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardRevealAgentArgs +} from '../../../../shared/dashboard-snapshot' import { AgentTerminalPreview } from './AgentTerminalPreview' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' /** Routing payload for focusing an agent's pane in the main window. */ -export type AgentRevealArgs = { - repoId: string - worktreeId: string - tabId: string - leafId: string | null -} +export type AgentRevealArgs = DashboardRevealAgentArgs type AgentTerminalDialogProps = { /** The agent shown in the dialog; null renders the dialog closed. */ @@ -26,6 +26,76 @@ type AgentTerminalDialogProps = { onReveal: (args: AgentRevealArgs) => void } +type AgentTerminalFrameProps = Omit & { + card: DashboardCard + title: React.ReactNode + previewClassName?: string +} + +function AgentTerminalFrame({ + card, + title, + previewClassName, + onOpenChange, + onReveal +}: AgentTerminalFrameProps): React.JSX.Element { + const reveal = (): void => { + onReveal({ + repoId: card.repoId, + worktreeId: card.worktreeId, + executionHostId: card.executionHostId, + tabId: card.tabId, + leafId: card.leafId + }) + onOpenChange(false) + } + + return ( + <> +
+ + + + {title} + + {formatAgentTypeLabel(card.agentType)} ·{' '} + {agentStateLabel(dashboardCardDisplayState(card))} + + +
+ {card.ptyId ? ( + + ) : ( +
+ {translate( + 'dashboardPopout.terminal.closed', + "No live terminal — this agent's pane has closed." + )} +
+ )} +
+ +
+ + ) +} + /** * The near-fullscreen live-terminal dialog for one agent. Hosted by the BOARD, * not the card: sending a message flips the agent's bucket, which remounts its @@ -38,18 +108,6 @@ export function AgentTerminalDialog({ onOpenChange, onReveal }: AgentTerminalDialogProps): React.JSX.Element { - const reveal = useCallback(() => { - if (!card) { - return - } - onReveal({ - repoId: card.repoId, - worktreeId: card.worktreeId, - tabId: card.tabId, - leafId: card.leafId - }) - }, [card, onReveal]) - return ( {card ? ( @@ -78,43 +136,68 @@ export function AgentTerminalDialog({ } }} > -
- - - - - {card.worktreeName} - - - {formatAgentTypeLabel(card.agentType)} · {agentStateLabel(card.dotState)} - - - - - {translate('dashboardPopout.terminal.close', 'Close')} - - -
- {card.ptyId ? ( - - ) : ( -
- {translate( - 'dashboardPopout.terminal.closed', - "No live terminal — this agent's pane has closed." - )} -
- )} -
- - - -
+ + {card.worktreeName} + + } + onOpenChange={onOpenChange} + onReveal={onReveal} + /> ) : null}
) } + +export function AgentTerminalPanel({ + card, + onOpenChange, + onReveal, + className +}: Omit & { + card: DashboardCard + className?: string +}): React.JSX.Element { + const titleId = useId() + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ( + event.key === 'Escape' && + !event.defaultPrevented && + !(event.target instanceof HTMLElement && event.target.closest('.xterm')) + ) { + onOpenChange(false) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onOpenChange]) + + return ( +
+ + {card.worktreeName} + + } + previewClassName="h-auto min-h-0 flex-1" + onOpenChange={onOpenChange} + onReveal={onReveal} + /> +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 8da354166f7..50d65df9193 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -49,11 +49,13 @@ function clamp(value: number, min: number, max: number): number { */ export function AgentTerminalPreview({ ptyId, - terminalInput = null + terminalInput = null, + className }: { ptyId: string /** Host-input facts relayed with the card; null routes bytes by client OS. */ terminalInput?: DashboardCardTerminalInput | null + className?: string }): React.JSX.Element { const containerRef = useRef(null) const terminalRef = useRef(null) @@ -444,7 +446,10 @@ export function AgentTerminalPreview({ // buffer is. The terminal keeps the pane's true dimensions and is scaled/ // clipped to fit; fitToBox anchors whichever end keeps the cursor in view.
{ptyGone ? ( diff --git a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx index a857b298b50..557036c569a 100644 --- a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx +++ b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx @@ -1,9 +1,9 @@ -import { AgentKanbanBoard } from './AgentKanbanBoard' +import { useEffect, useState } from 'react' +import { AgentKanbanBoard, type AgentDashboardView } from './AgentKanbanBoard' import { useDashboardSnapshot } from './useDashboardSnapshot' type DashboardPopoutRootProps = { - /** The layout requested via popout.html?view=. Only "kanban" exists - * today; unknown views fall back to it. */ + /** The layout requested via popout.html?view=. */ view: string | null } @@ -13,5 +13,9 @@ type DashboardPopoutRootProps = { */ export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element { const snapshot = useDashboardSnapshot() - return + const [view, setView] = useState(() => + _props.view === 'map' || _props.view === 'rings' ? 'map' : 'board' + ) + useEffect(() => window.api.dashboard.onViewRequested(setView), []) + return } diff --git a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts index d7a4c132150..14470931ca8 100644 --- a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts +++ b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts @@ -1,4 +1,8 @@ -import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot' +import type { + DashboardCard, + DashboardCardReview, + DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' export type DashboardReviewFilter = DashboardCardReview['state'] | 'none' @@ -36,6 +40,34 @@ function cardSearchText(card: DashboardCard): string { .toLocaleLowerCase() } +function workspaceSearchText(workspace: DashboardWorkspace): string { + return [ + workspace.worktreeName, + workspace.repoName, + workspace.review ? `#${workspace.review.number}` : '' + ] + .filter(Boolean) + .join(' ') + .toLocaleLowerCase() +} + +function workspaceMatchesFilters( + workspace: DashboardWorkspace, + normalizedQuery: string, + filters: DashboardFilters +): boolean { + const reviewState = workspace.review?.state ?? (workspace.hasReview ? null : 'none') + return ( + (normalizedQuery.length === 0 || workspaceSearchText(workspace).includes(normalizedQuery)) && + (filters.projects.length === 0 || filters.projects.includes(workspace.repoId)) && + (filters.workspaceStatuses.length === 0 || + (workspace.workspaceStatusId !== undefined && + filters.workspaceStatuses.includes(workspace.workspaceStatusId))) && + (filters.reviewStates.length === 0 || + (reviewState !== null && filters.reviewStates.includes(reviewState))) + ) +} + export function filterDashboardCards( cards: DashboardCard[], query: string, @@ -56,6 +88,17 @@ export function filterDashboardCards( }) } +export function filterDashboardWorkspaces( + workspaces: DashboardWorkspace[], + query: string, + filters: DashboardFilters +): DashboardWorkspace[] { + const normalizedQuery = query.trim().toLocaleLowerCase() + return workspaces.filter((workspace) => + workspaceMatchesFilters(workspace, normalizedQuery, filters) + ) +} + export function toggleDashboardFilter(values: T[], value: T): T[] { return values.includes(value) ? values.filter((candidate) => candidate !== value) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts b/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts new file mode 100644 index 00000000000..56978357359 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts @@ -0,0 +1,54 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +const GOLDEN_ANGLE = 2.399963229728653 + +function stableHash(value: string): number { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 +} + +export function placeAgentMapAgents({ + worktreeId, + cards, + radius, + agentRadius, + now +}: { + worktreeId: string + cards: DashboardCard[] + radius: number + agentRadius: number + now: number +}): { + card: DashboardCard + x: number + y: number + radius: number + durationMinutes: number + status: DashboardCard['dotState'] +}[] { + const availableRadius = Math.max(0, radius - agentRadius - 6) + const sorted = [...cards].sort((a, b) => + a.paneKey < b.paneKey ? -1 : a.paneKey > b.paneKey ? 1 : 0 + ) + const capacity = Math.ceil(Math.sqrt(Math.max(1, sorted.length))) ** 2 + const angleOffset = (stableHash(worktreeId) / 0xffffffff) * Math.PI * 2 + + return sorted.map((card, index) => { + const orbit = sorted.length === 1 ? 0 : Math.sqrt((index + 0.5) / capacity) * availableRadius + const angle = angleOffset + index * GOLDEN_ANGLE + return { + card, + x: Math.cos(angle) * orbit, + y: Math.sin(angle) * orbit, + radius: agentRadius, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + } + }) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts new file mode 100644 index 00000000000..4269759c99c --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { agentMapState, countAgentMapCards, filterAgentMapCards } from './agent-map-filter' + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'done', + dotState: 'done', + task: '', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + startedAt: NOW - 60_000, + finishedAt: NOW - 30_000, + stateChangedAt: NOW - 30_000, + unseen: false, + hostKind: 'local', + ...overrides + } +} + +describe('agent map filtering', () => { + it('keeps unseen completions done and settles acknowledged completions into idle', () => { + expect(agentMapState(card({ unseen: true }))).toBe('done') + expect(agentMapState(card({ unseen: false }))).toBe('idle') + expect(agentMapState(card({ bucket: 'idle', dotState: 'idle' }))).toBe('idle') + }) + + it('applies state and host filters independently', () => { + const hidden = card({ paneKey: 'hidden', repoId: 'hidden', hostKind: 'ssh', unseen: true }) + const visible = filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['done']), + hostFilter: 'ssh' + }) + + expect(visible).toEqual([hidden]) + expect( + filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['idle']), + hostFilter: 'ssh' + }) + ).toEqual([]) + expect( + filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['done']), + hostFilter: 'local' + }) + ).toEqual([]) + }) + + it('counts all four display states', () => { + const cards = [ + card({ paneKey: 'done-new', unseen: true }), + card({ paneKey: 'done-seen', unseen: false }), + card({ paneKey: 'working', bucket: 'working', dotState: 'working', finishedAt: null }), + card({ paneKey: 'waiting', bucket: 'attention', dotState: 'waiting', finishedAt: null }) + ] + + expect(countAgentMapCards(cards)).toEqual({ + attention: 1, + working: 1, + done: 1, + idle: 1 + }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts new file mode 100644 index 00000000000..3a137ff93bd --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts @@ -0,0 +1,48 @@ +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardHostKind +} from '../../../../shared/dashboard-snapshot' + +export type AgentMapState = 'attention' | 'working' | 'done' | 'idle' +export type AgentMapHostFilter = 'all' | DashboardCardHostKind +export type AgentMapCounts = Record + +export function agentMapState(card: DashboardCard): AgentMapState { + const state = dashboardCardDisplayState(card) + if (state === 'blocked' || state === 'waiting') { + return 'attention' + } + return state +} + +export function filterAgentMapCards({ + cards, + enabledStates, + hostFilter +}: { + cards: DashboardCard[] + enabledStates: ReadonlySet + hostFilter: AgentMapHostFilter +}): DashboardCard[] { + // Project filtering lives in the shared toolbar filter, which has already + // narrowed these cards. + return cards.filter( + (card) => + (hostFilter === 'all' || (card.hostKind ?? 'local') === hostFilter) && + enabledStates.has(agentMapState(card)) + ) +} + +export function countAgentMapCards(cards: DashboardCard[]): AgentMapCounts { + const counts: AgentMapCounts = { + attention: 0, + working: 0, + done: 0, + idle: 0 + } + for (const card of cards) { + counts[agentMapState(card)] += 1 + } + return counts +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts new file mode 100644 index 00000000000..9403974b62b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function source(file: string): string { + return readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout', file), + 'utf8' + ) +} + +describe('Agent Map glow performance boundary', () => { + it('uses one conditional SVG halo per active entity without filter effects', () => { + const component = source('AgentMapWorktreeRingNode.tsx') + + expect(component.match(/data-agent-map-worktree-status-glow/g)).toHaveLength(1) + expect(component.match(/data-agent-map-agent-working-glow/g)).toHaveLength(1) + expect(component).not.toMatch(/ { + const css = source('agent-map.css') + const glowRules = css.match(/\.agent-map-(?:worktree-status|agent-working)-glow\s*\{[^}]+\}/gs) + + expect(glowRules).toHaveLength(2) + for (const rule of glowRules ?? []) { + expect(rule).toContain('pointer-events: none') + expect(rule).toContain('vector-effect: non-scaling-stroke') + expect(rule).not.toMatch(/filter:|animation:|transition:/) + } + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts new file mode 100644 index 00000000000..28c71c881a1 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' +import type { + AgentMapLayout, + AgentMapProjectRing, + AgentMapStatusCounts, + AgentMapWorktreeRing +} from './agent-map-layout' + +function statusCounts(overrides: Partial = {}): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0, ...overrides } +} + +function worktree(overrides: Partial = {}): AgentMapWorktreeRing { + const counts = overrides.statusCounts ?? statusCounts({ working: 1 }) + const total = Object.values(counts).reduce((sum, value) => sum + value, 0) + return { + id: 'worktree-a', + worktreeId: 'worktree-a', + executionHostId: undefined, + name: 'alpha', + workspaceKind: 'worktree', + x: 0, + y: 0, + radius: 62, + // Sparse placeholders keep tests that only care about label-to-label collisions concise. + agents: Array.from({ length: total }) as AgentMapWorktreeRing['agents'], + statusCounts: counts, + quiet: counts.idle === total, + ...overrides + } +} + +function layoutOf( + worktrees: AgentMapWorktreeRing[], + project: Partial = {} +): AgentMapLayout { + return { + projects: [ + { + id: 'project-1', + name: 'orca', + x: 0, + // Parked far above the workspaces so the project label is not itself + // the thing under test unless a case moves it. + y: -4_000, + radius: 100, + worktrees, + agentCount: worktrees.reduce((sum, item) => sum + item.agents.length, 0), + ...project + } + ], + width: 900, + height: 560, + topologyKey: 'test' + } +} + +describe('selectVisibleAgentMapLabels', () => { + it('keeps both labels when they are far enough apart', () => { + const layout = layoutOf([ + worktree({ id: 'a', x: -400, y: 0 }), + worktree({ id: 'b', name: 'beta', x: 400, y: 0 }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds].sort()).toEqual(['a', 'b']) + }) + + it('drops the lower-priority label when two would overlap', () => { + // Same anchor point: the two labels are drawn on top of each other. + const layout = layoutOf([ + worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), + worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['busy']) + }) + + it('hides a workspace title that would cover an agent', () => { + const coveringAgent = { x: 0, y: -48, radius: 20 } + const covered = worktree({ + id: 'covered', + agents: [coveringAgent] as AgentMapWorktreeRing['agents'] + }) + + const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1) + + expect(labels.worktreeIds.size).toBe(0) + expect(labels.worktreeAgentSafeIds.size).toBe(0) + }) + + it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => { + const layout = layoutOf([ + worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), + worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) + ]) + + const labels = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...labels.worktreeIds]).toEqual(['busy']) + expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm']) + }) + + it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => { + const layout = layoutOf([ + worktree({ id: 'blocked', x: 0, y: 0, statusCounts: statusCounts({ blocked: 1 }) }), + worktree({ + id: 'working', + name: 'beta', + x: 0, + y: 0, + statusCounts: statusCounts({ working: 9 }) + }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['blocked']) + }) + + it('hides all-idle workspace labels until the ring is large on screen', () => { + const idle = worktree({ id: 'idle', x: 0, y: 0, statusCounts: statusCounts({ idle: 2 }) }) + + expect(selectVisibleAgentMapLabels(layoutOf([idle]), 1, 0.5).worktreeIds.size).toBe(0) + expect([...selectVisibleAgentMapLabels(layoutOf([idle]), 1, 1).worktreeIds]).toEqual(['idle']) + }) + + it('drops the project count rather than a workspace name when they collide', () => { + // The workspace ring's name lands on the project's count line. + const layout = layoutOf([worktree({ id: 'a', x: 0, y: 42 })], { + x: 0, + y: 0, + radius: 40, + agentCount: 1 + }) + + const { worktreeIds, projectCountIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['a']) + expect(projectCountIds.size).toBe(0) + }) + + it('keeps the project count when nothing is in its way', () => { + const layout = layoutOf([worktree({ id: 'a', x: 0, y: 0 })]) + + expect([...selectVisibleAgentMapLabels(layout, 1, 1).projectCountIds]).toEqual(['project-1']) + }) + + it('admits more labels as zooming in shrinks their world footprint', () => { + const worktrees = Array.from({ length: 6 }, (_unused, index) => + worktree({ id: `w-${index}`, name: `workspace-${index}`, x: index * 90, y: 0 }) + ) + + const zoomedOut = selectVisibleAgentMapLabels(layoutOf(worktrees), 4, 0.25).worktreeIds + const zoomedIn = selectVisibleAgentMapLabels(layoutOf(worktrees), 1, 1).worktreeIds + + expect(zoomedOut.size).toBeLessThan(zoomedIn.size) + expect(zoomedIn.size).toBe(6) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts new file mode 100644 index 00000000000..020d8453dd2 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -0,0 +1,273 @@ +import type { AgentMapLayout, AgentMapWorktreeRing } from './agent-map-layout' + +/** Metrics for the label styles in agent-map.css. Estimating text extents from + * the character count avoids a per-frame DOM measure of every label. */ +const WORKTREE_LABEL_FONT_PX = 12 +const PROJECT_LABEL_FONT_PX = 13 +const COUNT_FONT_PX = 11 +const GLYPH_WIDTH_RATIO = 0.56 +/** Uppercase project text runs wider per glyph than a mixed-case worktree name. */ +const UPPERCASE_GLYPH_WIDTH_RATIO = 0.66 +const ASCENT_RATIO = 0.8 +const DESCENT_RATIO = 0.2 +const PROJECT_LABEL_ICON_PX = 16 +/** Local-unit breathing room so two labels never appear to touch. */ +const LABEL_GAP_X_PX = 3 +const LABEL_GAP_Y_PX = 1 +const AGENT_LABEL_CLEARANCE_PX = 3 +/** Baselines the scene renders at, relative to each label group's origin. */ +const WORKTREE_LABEL_BASELINE = 18 +const COUNT_BASELINE = 32 +const PROJECT_NAME_TOP = 3 +const PROJECT_NAME_BOTTOM = 21 +/** Past this many candidates the pass stops admitting labels; a map that dense + * is unreadable long before the cap, and this bounds the work. */ +const MAX_LABEL_CANDIDATES = 600 +const DECLUTTER_GRID_PX = 96 + +/** A label box in world units, matching what the scene actually renders. */ +type LabelBox = { + left: number + right: number + top: number + bottom: number +} + +type LabelGrid = Map> + +export type AgentMapVisibleLabels = { + /** Worktree ring ids whose name can render without colliding. */ + worktreeIds: Set + /** Worktree names that hover/focus may reveal without covering an agent. */ + worktreeAgentSafeIds: Set + /** Project ids whose agent/workspace count line still has room. The count is + * the first thing dropped: it repeats what the filter rail already says. */ + projectCountIds: Set +} + +function textWidth(text: string, fontPx: number, ratio = GLYPH_WIDTH_RATIO): number { + return text.length * fontPx * ratio +} + +export function agentMapWorktreeLabelWidth(text: string): number { + return textWidth(text, WORKTREE_LABEL_FONT_PX) +} + +export function agentMapCountLabelWidth(text: string): number { + return textWidth(text, COUNT_FONT_PX) +} + +function boxesOverlap(a: LabelBox, b: LabelBox): boolean { + return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom +} + +function addBox(grid: LabelGrid, box: LabelBox): void { + const left = Math.floor(box.left / DECLUTTER_GRID_PX) + const right = Math.floor(box.right / DECLUTTER_GRID_PX) + const top = Math.floor(box.top / DECLUTTER_GRID_PX) + const bottom = Math.floor(box.bottom / DECLUTTER_GRID_PX) + for (let x = left; x <= right; x += 1) { + let column = grid.get(x) + if (!column) { + column = new Map() + grid.set(x, column) + } + for (let y = top; y <= bottom; y += 1) { + const cell = column.get(y) + if (cell) { + cell.push(box) + } else { + column.set(y, [box]) + } + } + } +} + +function collides(grid: LabelGrid, box: LabelBox): boolean { + const left = Math.floor(box.left / DECLUTTER_GRID_PX) + const right = Math.floor(box.right / DECLUTTER_GRID_PX) + const top = Math.floor(box.top / DECLUTTER_GRID_PX) + const bottom = Math.floor(box.bottom / DECLUTTER_GRID_PX) + for (let x = left; x <= right; x += 1) { + const column = grid.get(x) + if (!column) { + continue + } + for (let y = top; y <= bottom; y += 1) { + for (const placed of column.get(y) ?? []) { + if (boxesOverlap(box, placed)) { + return true + } + } + } + } + return false +} + +/** Projects a centered label from its group's local units into world space + * through the same scale the scene applies to the group. */ +function centeredBox( + centerX: number, + anchorY: number, + scale: number, + width: number, + localTop: number, + localBottom: number +): LabelBox { + const halfWidth = (width / 2 + LABEL_GAP_X_PX) * scale + return { + left: centerX - halfWidth, + right: centerX + halfWidth, + top: anchorY + (localTop - LABEL_GAP_Y_PX) * scale, + bottom: anchorY + (localBottom + LABEL_GAP_Y_PX) * scale + } +} + +/** Box for a centered given its baseline in the group's local units. */ +function baselineBox( + centerX: number, + anchorY: number, + scale: number, + text: string, + fontPx: number, + baseline: number, + widthRatio = GLYPH_WIDTH_RATIO +): LabelBox { + return centeredBox( + centerX, + anchorY, + scale, + textWidth(text, fontPx, widthRatio), + baseline - fontPx * ASCENT_RATIO, + baseline + fontPx * DESCENT_RATIO + ) +} + +/** Attention outranks volume: a blocked workspace keeps its name when a large + * idle neighbour has to drop its own. */ +function labelPriority(worktree: AgentMapWorktreeRing): number { + const attention = worktree.statusCounts.blocked + worktree.statusCounts.waiting + return attention * 1_000_000 + worktree.statusCounts.working * 10_000 + worktree.agents.length +} + +/** Worth drawing before collisions are considered: all-idle workspaces stay + * silent until their ring is big enough on screen to be worth naming. */ +function isLabelCandidate(worktree: AgentMapWorktreeRing, mapScale: number): boolean { + return !worktree.quiet || worktree.radius * mapScale >= 56 +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function addAgentExclusionBoxes(grid: LabelGrid, layout: AgentMapLayout, mapScale: number): void { + const clearance = AGENT_LABEL_CLEARANCE_PX / Math.max(mapScale, 0.001) + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + for (const agent of worktree.agents) { + if (!agent) { + continue + } + const radius = agent.radius + clearance + addBox(grid, { + left: agent.x - radius, + right: agent.x + radius, + top: agent.y - radius, + bottom: agent.y + radius + }) + } + } + } +} + +/** + * Picks the labels that can render without stacking on each other. Labels draw + * at a fixed screen size, so which ones fit depends on zoom but never on pan — + * keeping this pan-independent is what lets the scene stay memoized during a + * drag. Project names claim space first as the map's coarsest landmark, + * workspace names next in priority order, and project counts take what is left. + */ +export function selectVisibleAgentMapLabels( + layout: AgentMapLayout, + labelScale: number, + mapScale: number +): AgentMapVisibleLabels { + const agentGrid: LabelGrid = new Map() + addAgentExclusionBoxes(agentGrid, layout, mapScale) + const grid: LabelGrid = new Map() + addAgentExclusionBoxes(grid, layout, mapScale) + for (const project of layout.projects) { + const name = project.name.toUpperCase() + addBox( + grid, + centeredBox( + project.x, + project.y - project.radius, + labelScale, + PROJECT_LABEL_ICON_PX + textWidth(name, PROJECT_LABEL_FONT_PX, UPPERCASE_GLYPH_WIDTH_RATIO), + PROJECT_NAME_TOP, + PROJECT_NAME_BOTTOM + ) + ) + } + + const candidates: AgentMapWorktreeRing[] = [] + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + if (isLabelCandidate(worktree, mapScale)) { + candidates.push(worktree) + } + } + } + candidates.sort((a, b) => { + const byPriority = labelPriority(b) - labelPriority(a) + if (byPriority !== 0) { + return byPriority + } + const byRadius = b.radius - a.radius + return byRadius !== 0 ? byRadius : compareStable(a.id, b.id) + }) + + const worktreeIds = new Set() + const worktreeAgentSafeIds = new Set() + for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) { + const box = baselineBox( + worktree.x, + worktree.y - worktree.radius, + labelScale, + worktree.name, + WORKTREE_LABEL_FONT_PX, + WORKTREE_LABEL_BASELINE + ) + if (collides(agentGrid, box)) { + continue + } + worktreeAgentSafeIds.add(worktree.id) + if (collides(grid, box)) { + continue + } + addBox(grid, box) + worktreeIds.add(worktree.id) + } + + const projectCountIds = new Set() + for (const project of layout.projects) { + const count = `${project.agentCount} AGENTS · ${project.worktrees.length} WORKSPACES` + const box = baselineBox( + project.x, + project.y - project.radius, + labelScale, + count, + COUNT_FONT_PX, + COUNT_BASELINE, + UPPERCASE_GLYPH_WIDTH_RATIO + ) + if (collides(grid, box)) { + continue + } + addBox(grid, box) + projectCountIds.add(project.id) + } + + return { worktreeIds, worktreeAgentSafeIds, projectCountIds } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts new file mode 100644 index 00000000000..69238b73cf3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts @@ -0,0 +1,53 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { AgentMapLayout, AgentMapStatusCounts } from './agent-map-layout' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +function emptyStatusCounts(): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 } +} + +export function refreshAgentMapMetadata( + geometry: AgentMapLayout, + cards: DashboardCard[], + now: number +): AgentMapLayout { + const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) + const projects = geometry.projects.map((project) => { + let projectName = project.name + let agentCount = 0 + const worktrees = project.worktrees.map((worktree) => { + let worktreeName = worktree.name + let workspaceKind = worktree.workspaceKind + const statusCounts = emptyStatusCounts() + const agents = worktree.agents.flatMap((agent) => { + const card = cardsByPaneKey.get(agent.card.paneKey) + if (!card) { + return [] + } + projectName = card.repoName + worktreeName = card.worktreeName + workspaceKind = card.workspaceKind ?? 'worktree' + agentCount += 1 + statusCounts[agentMapNodeStatus(card)] += 1 + return [ + { + ...agent, + card, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + } + ] + }) + return { + ...worktree, + name: worktreeName, + workspaceKind, + agents, + statusCounts, + quiet: statusCounts.idle === agents.length + } + }) + return { ...project, name: projectName, worktrees, agentCount } + }) + return { ...geometry, projects } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts new file mode 100644 index 00000000000..52c47be08f5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts @@ -0,0 +1,604 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { + deriveAgentMapLayout, + AGENT_MAP_AGENT_RADIUS, + AGENT_MAP_RING_HEADER_HEIGHT, + AGENT_MAP_WORKTREE_GAP, + agentMapDurationMinutes, + agentMapNodeStatus, + updateAgentMapLayout +} from './agent-map-layout' +import type * as WorktreePackingModule from './agent-map-worktree-packing' + +const packWorktrees = vi.hoisted(() => vi.fn()) +vi.mock('./agent-map-worktree-packing', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + packAgentMapWorktrees: (...args: Parameters) => { + packWorktrees() + return actual.packAgentMapWorktrees(...args) + } + } +}) + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + ...overrides + } +} + +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'repo-1', + worktreeId: 'empty-worktree', + repoName: 'Orca', + worktreeName: 'Empty worktree', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +describe('agent map layout', () => { + it('lays out agentless workspaces and preserves their workspace lineage', () => { + const layout = deriveAgentMapLayout([], NOW, [ + workspace({ worktreeId: 'parent', worktreeName: 'Parent' }), + workspace({ + worktreeId: 'child', + worktreeName: 'Child', + parentWorktreeId: 'parent' + }) + ]) + const project = layout.projects[0] + const parent = project.worktrees.find((item) => item.worktreeId === 'parent')! + const child = project.worktrees.find((item) => item.worktreeId === 'child')! + + expect(project.agentCount).toBe(0) + expect(parent.agents).toEqual([]) + expect(child.parentId).toBe(parent.id) + expect(child.y).toBeGreaterThan(parent.y) + }) + + it('derives project containment, workspace containment, and every agent node', () => { + const cards = [ + card({ paneKey: 'a', repoId: 'repo-a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', repoId: 'repo-a', worktreeId: 'wt-a' }), + card({ paneKey: 'c', repoId: 'repo-a', worktreeId: 'wt-b' }), + card({ + paneKey: 'd', + repoId: 'repo-b', + repoName: 'Mobile', + worktreeId: 'wt-c' + }) + ] + const layout = deriveAgentMapLayout(cards, NOW) + + expect(layout.projects.map((project) => project.id)).toEqual(['repo-a', 'repo-b']) + expect(layout.projects[0].worktrees.map((worktree) => worktree.worktreeId)).toEqual([ + 'wt-a', + 'wt-b' + ]) + expect( + layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => worktree.agents.map((agent) => agent.card.paneKey)) + ) + ).toEqual(['a', 'b', 'c', 'd']) + + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + expect( + Math.hypot(worktree.x - project.x, worktree.y - project.y) + worktree.radius + ).toBeLessThan(project.radius) + for (const agent of worktree.agents) { + expect( + Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius + ).toBeLessThan(worktree.radius) + } + } + } + }) + + it('keeps exact worktree IDs on different hosts in separate rings', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'local', executionHostId: 'local' }), + card({ paneKey: 'remote', executionHostId: 'runtime:env-1' }) + ], + NOW + ) + + expect(layout.projects[0].worktrees).toHaveLength(2) + expect( + layout.projects[0].worktrees.map(({ worktreeId, executionHostId }) => ({ + worktreeId, + executionHostId + })) + ).toEqual( + expect.arrayContaining([ + { worktreeId: 'worktree-1', executionHostId: 'local' }, + { worktreeId: 'worktree-1', executionHostId: 'runtime:env-1' } + ]) + ) + }) + + it('reserves project and workspace header bands above dense ring contents', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 24 }, (_unused, index) => + card({ paneKey: `dense-${index.toString().padStart(2, '0')}` }) + ), + NOW + ) + const project = layout.projects[0] + const worktree = project.worktrees[0] + const projectTop = project.y - project.radius + const worktreeTop = worktree.y - worktree.radius + const agentTop = Math.min(...worktree.agents.map((agent) => agent.y - agent.radius)) + + expect(worktreeTop - projectTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT) + expect(agentTop - worktreeTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT) + }) + + it('keeps sparse project and workspace rings compact around their header bands', () => { + const single = deriveAgentMapLayout([card()], NOW).projects[0] + const four = deriveAgentMapLayout( + Array.from({ length: 4 }, (_unused, index) => card({ paneKey: `agent-${index}` })), + NOW + ).projects[0] + + expect(single.worktrees[0].radius).toBeLessThanOrEqual(72) + expect(single.radius).toBeLessThanOrEqual(104) + expect(four.worktrees[0].radius).toBeLessThanOrEqual(100) + }) + + it.each([ + ['single', [card()]], + [ + 'sparse', + [ + card({ paneKey: 'a', repoId: 'repo-a' }), + card({ paneKey: 'b', repoId: 'repo-b', worktreeId: 'worktree-2' }) + ] + ] + ])('centers %s project content within the minimum world', (_, cards) => { + const layout = deriveAgentMapLayout(cards, NOW) + const left = Math.min(...layout.projects.map((project) => project.x - project.radius)) + const right = Math.max(...layout.projects.map((project) => project.x + project.radius)) + const top = Math.min(...layout.projects.map((project) => project.y - project.radius)) + const bottom = Math.max(...layout.projects.map((project) => project.y + project.radius)) + + expect(layout.width).toBe(900) + expect(layout.height).toBe(560) + expect((left + right) / 2).toBeCloseTo(layout.width / 2) + expect((top + bottom) / 2).toBeCloseTo(layout.height / 2) + }) + + it('places spawned descendants beneath their direct parent inside the workspace', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent' }), + card({ paneKey: 'child-a', parentPaneKey: 'parent' }), + card({ paneKey: 'child-b', parentPaneKey: 'parent' }), + card({ paneKey: 'grandchild', parentPaneKey: 'child-a' }) + ], + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + const parent = agents.get('parent')! + const childA = agents.get('child-a')! + const childB = agents.get('child-b')! + const grandchild = agents.get('grandchild')! + + expect(childA.y).toBeGreaterThan(parent.y) + expect(childB.y).toBeGreaterThan(parent.y) + expect(grandchild.y).toBeGreaterThan(childA.y) + for (const agent of worktree.agents) { + expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan( + worktree.radius + ) + } + }) + + it('packs high-fanout spawned children into a compact deterministic cluster', () => { + const cards = [ + card({ paneKey: 'parent' }), + ...Array.from({ length: 29 }, (_, index) => + card({ + paneKey: `child-${index.toString().padStart(2, '0')}`, + parentPaneKey: 'parent' + }) + ) + ] + const first = deriveAgentMapLayout(cards, NOW).projects[0].worktrees[0] + const second = deriveAgentMapLayout(cards, NOW).projects[0].worktrees[0] + const parent = first.agents.find((agent) => agent.card.paneKey === 'parent')! + const children = first.agents.filter((agent) => agent.card.parentPaneKey === 'parent') + + expect(new Set(children.map((child) => child.y.toFixed(3))).size).toBeGreaterThan(4) + expect(children.every((child) => child.y > parent.y)).toBe(true) + expect( + Math.max(...children.map((child) => child.x)) - Math.min(...children.map((child) => child.x)) + ).toBeLessThan(500) + expect(first.radius).toBeLessThan(350) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + expect(Math.hypot(child.x - other.x, child.y - other.y)).toBeGreaterThanOrEqual( + AGENT_MAP_AGENT_RADIUS * 2 + ) + } + } + expect(first.agents.map(({ card, x, y }) => ({ paneKey: card.paneKey, x, y }))).toEqual( + second.agents.map(({ card, x, y }) => ({ paneKey: card.paneKey, x, y })) + ) + }) + + it('places visible child worktrees beneath their direct parent', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child-a', + worktreeId: 'child-a-worktree', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'child-b', + worktreeId: 'child-b-worktree', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'grandchild', + worktreeId: 'grandchild-worktree', + parentWorktreeId: 'child-a-worktree' + }) + ], + NOW + ) + const worktrees = new Map( + layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree]) + ) + const parent = worktrees.get('parent-worktree')! + const childA = worktrees.get('child-a-worktree')! + const childB = worktrees.get('child-b-worktree')! + const grandchild = worktrees.get('grandchild-worktree')! + + expect(childA.y).toBeGreaterThan(parent.y) + expect(childB.y).toBeGreaterThan(parent.y) + expect(grandchild.y).toBeGreaterThan(childA.y) + for (const [index, worktree] of layout.projects[0].worktrees.entries()) { + for (const other of layout.projects[0].worktrees.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('clusters cross-worktree spawned agents without inventing workspace lineage', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child', + worktreeId: 'child-worktree', + parentPaneKey: 'parent' + }), + card({ paneKey: 'unrelated', worktreeId: 'unrelated-worktree' }) + ], + NOW + ) + const worktrees = new Map( + layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree]) + ) + const parent = worktrees.get('parent-worktree')! + const child = worktrees.get('child-worktree')! + + expect(child.clusterParentId).toBe(parent.id) + expect(child.parentId).toBeUndefined() + expect(child.y).toBeGreaterThan(parent.y) + expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan( + child.radius + parent.radius + 100 + ) + }) + + it('clusters spawned agents whose parent is in another project', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', repoId: 'repo-parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child', + repoId: 'repo-child', + worktreeId: 'child-worktree', + parentPaneKey: 'parent' + }), + card({ paneKey: 'unrelated', repoId: 'repo-unrelated', worktreeId: 'unrelated-worktree' }) + ], + NOW + ) + const projects = new Map(layout.projects.map((project) => [project.id, project])) + const parent = projects.get('repo-parent')! + const child = projects.get('repo-child')! + + expect(child.y).toBeGreaterThan(parent.y) + expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan( + child.radius + parent.radius + 100 + ) + }) + + it('repacks cached geometry when a worktree parent changes', () => { + const cards = [ + card({ paneKey: 'parent-a', worktreeId: 'parent-a' }), + card({ paneKey: 'parent-b', worktreeId: 'parent-b' }), + card({ paneKey: 'child', worktreeId: 'child', parentWorktreeId: 'parent-a' }) + ] + const initial = updateAgentMapLayout(null, cards, NOW) + const updated = updateAgentMapLayout( + initial.cache, + cards.map((candidate) => + candidate.worktreeId === 'child' + ? { ...candidate, parentWorktreeId: 'parent-b' } + : candidate + ), + NOW + ) + + expect(updated.cache).not.toBe(initial.cache) + expect(updated.cache.packingGeneration).toBe(2) + }) + + it('repacks cached geometry when a spawn parent changes', () => { + const cards = [ + card({ paneKey: 'parent-a' }), + card({ paneKey: 'parent-b' }), + card({ paneKey: 'child', parentPaneKey: 'parent-a' }) + ] + const initial = updateAgentMapLayout(null, cards, NOW) + const updated = updateAgentMapLayout( + initial.cache, + cards.map((candidate) => + candidate.paneKey === 'child' ? { ...candidate, parentPaneKey: 'parent-b' } : candidate + ), + NOW + ) + + expect(updated.cache).not.toBe(initial.cache) + expect(updated.cache.packingGeneration).toBe(2) + expect(updated.layout.topologyKey).not.toBe(initial.layout.topologyKey) + }) + + it('keeps positions stable across routine status and duration updates', () => { + const initialCards = [ + card({ paneKey: 'a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', worktreeId: 'wt-a', startedAt: NOW - 2 * 60_000 }), + card({ paneKey: 'c', worktreeId: 'wt-b' }) + ] + const initial = deriveAgentMapLayout(initialCards, NOW) + const updated = deriveAgentMapLayout( + [ + { ...initialCards[0], bucket: 'attention', dotState: 'waiting' }, + { ...initialCards[1], startedAt: NOW - 45 * 60_000 }, + initialCards[2] + ], + NOW + ) + const initialAgents = initial.projects[0].worktrees[0].agents + const updatedAgents = updated.projects[0].worktrees[0].agents + const initialWorktrees = initial.projects[0].worktrees + const updatedWorktrees = updated.projects[0].worktrees + + expect(updated.topologyKey).toBe(initial.topologyKey) + expect(updatedWorktrees.map(({ x, y }) => ({ x, y }))).toEqual( + initialWorktrees.map(({ x, y }) => ({ x, y })) + ) + expect(updatedAgents.map(({ x, y }) => ({ x, y }))).toEqual( + initialAgents.map(({ x, y }) => ({ x, y })) + ) + expect(updatedAgents[1].radius).toBe(initialAgents[1].radius) + }) + + it('reuses packed geometry while refreshing live card metadata', () => { + const initialCards = [ + card({ paneKey: 'a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', worktreeId: 'wt-b' }) + ] + const initial = updateAgentMapLayout(null, initialCards, NOW) + packWorktrees.mockClear() + const updatedCards = [ + { ...initialCards[0], dotState: 'waiting' as const, worktreeName: 'Renamed' }, + { ...initialCards[1], startedAt: NOW - 60 * 60_000 } + ] + const updated = updateAgentMapLayout(initial.cache, updatedCards, NOW + 60_000) + + expect(updated.cache).toBe(initial.cache) + expect(packWorktrees).not.toHaveBeenCalled() + expect(initial.cache.geometry).toBe(initial.layout) + expect(updated.cache.packingGeneration).toBe(1) + expect(updated.layout.projects[0].worktrees[0].name).toBe('Renamed') + expect(updated.layout.projects[0].worktrees[0].statusCounts.waiting).toBe(1) + expect(updated.layout.projects[0].worktrees[1].agents[0].durationMinutes).toBe(61) + + const topologyChanged = updateAgentMapLayout( + updated.cache, + [...updatedCards, card({ paneKey: 'c', worktreeId: 'wt-c' })], + NOW + ) + expect(topologyChanged.cache).not.toBe(updated.cache) + expect(packWorktrees).toHaveBeenCalled() + expect(topologyChanged.cache.packingGeneration).toBe(2) + }) + + it('packs worktree rings tightly without a square grid', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 36 }, (_, index) => + card({ + paneKey: `agent-${index}`, + worktreeId: `worktree-${index.toString().padStart(2, '0')}` + }) + ), + NOW + ) + const project = layout.projects[0] + + expect(project.radius).toBeLessThan(700) + expect( + new Set(project.worktrees.map((worktree) => worktree.x.toFixed(3))).size + ).toBeGreaterThan(12) + expect( + new Set(project.worktrees.map((worktree) => worktree.y.toFixed(3))).size + ).toBeGreaterThan(12) + for (const [index, worktree] of project.worktrees.entries()) { + for (const other of project.worktrees.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('uses one agent size while retaining elapsed duration', () => { + const finished = card({ + startedAt: NOW - 30 * 60_000, + finishedAt: NOW - 20 * 60_000 + }) + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'just-started', startedAt: NOW }), + card({ paneKey: 'long-running', startedAt: NOW - 24 * 60 * 60_000 }) + ], + NOW + ) + + expect(layout.projects[0].worktrees[0].agents.map((agent) => agent.radius)).toEqual([ + AGENT_MAP_AGENT_RADIUS, + AGENT_MAP_AGENT_RADIUS + ]) + expect(agentMapDurationMinutes(finished, NOW)).toBe(10) + }) + + it('maps acknowledged completions to idle independently from elapsed time', () => { + for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) { + expect(agentMapNodeStatus(card({ dotState }))).toBe(dotState) + } + expect(agentMapNodeStatus(card({ dotState: 'done', unseen: true }))).toBe('done') + expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('idle') + const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 }) + const longBlocked = card({ dotState: 'blocked', startedAt: NOW - 45 * 60_000 }) + expect(agentMapNodeStatus(shortBlocked)).toBe(agentMapNodeStatus(longBlocked)) + }) + + it('marks only operationally quiet workspaces for semantic aggregation', () => { + const quiet = deriveAgentMapLayout( + Array.from({ length: 5 }, (_, index) => + card({ paneKey: `quiet-${index}`, dotState: index === 0 ? 'done' : 'idle' }) + ), + NOW + ) + const active = deriveAgentMapLayout([card({ paneKey: 'active', dotState: 'working' })], NOW) + const unseenDone = deriveAgentMapLayout( + Array.from({ length: 5 }, (_, index) => + card({ paneKey: `done-${index}`, dotState: 'done', unseen: true }) + ), + NOW + ) + + expect(quiet.projects[0].worktrees[0].quiet).toBe(true) + expect(active.projects[0].worktrees[0].quiet).toBe(false) + expect(unseenDone.projects[0].worktrees[0].quiet).toBe(false) + }) + + it('places hundreds of agents in one workspace without overlap', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 400 }, (_, index) => card({ paneKey: `agent-${index}` })), + NOW + ) + const worktree = layout.projects[0].worktrees[0] + + expect(worktree.agents).toHaveLength(400) + let minimumDistance = Number.POSITIVE_INFINITY + for (const [index, agent] of worktree.agents.entries()) { + expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan( + worktree.radius + ) + for (const other of worktree.agents.slice(index + 1)) { + minimumDistance = Math.min( + minimumDistance, + Math.hypot(agent.x - other.x, agent.y - other.y) + ) + } + } + expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2) + }) + + it('keeps deeply nested spawn lineage finite without recursive stack growth', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 5_000 }, (_, index) => + card({ + paneKey: `agent-${index.toString().padStart(4, '0')}`, + parentPaneKey: + index === 0 ? undefined : `agent-${(index - 1).toString().padStart(4, '0')}` + }) + ), + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + + expect(worktree.agents).toHaveLength(5_000) + expect(Number.isFinite(worktree.radius)).toBe(true) + expect(worktree.radius).toBeLessThan(1_000_000) + for (const agent of worktree.agents) { + if (agent.card.parentPaneKey) { + expect(agent.y).toBeGreaterThan(agents.get(agent.card.parentPaneKey)!.y) + } + } + }) + + it('wraps very large spawn fanout without overlap', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent' }), + ...Array.from({ length: 300 }, (_, index) => + card({ paneKey: `child-${index}`, parentPaneKey: 'parent' }) + ) + ], + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const parent = worktree.agents.find((agent) => agent.card.paneKey === 'parent')! + const children = worktree.agents.filter((agent) => agent.card.parentPaneKey === 'parent') + let minimumDistance = Number.POSITIVE_INFINITY + + expect(children.every((child) => child.y > parent.y)).toBe(true) + expect(worktree.radius).toBeLessThan(1_000) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + minimumDistance = Math.min( + minimumDistance, + Math.hypot(child.x - other.x, child.y - other.y) + ) + } + } + expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts new file mode 100644 index 00000000000..68602f95c04 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts @@ -0,0 +1,308 @@ +import type * as DashboardSnapshotTypes from '../../../../shared/dashboard-snapshot' +import { placeAgentMapAgents } from './agent-map-agent-placement' +import { layoutAgentMapLineage } from './agent-map-lineage-layout' +import { refreshAgentMapMetadata } from './agent-map-layout-metadata' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' +import { placeAgentMapProjects } from './agent-map-project-placement' +import { selectAgentMapSpawnParentContainer } from './agent-map-spawn-clustering' +import { + agentMapCardTopologyIdentity, + agentMapWorkspaceIdentity, + agentMapWorkspaceTopologyIdentity, + agentMapWorktreeIdentity, + agentMapWorktreeIdentityFromParts +} from './agent-map-workspace-identity' +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +type DashboardCard = DashboardSnapshotTypes.DashboardCard +type DashboardCardDotState = DashboardSnapshotTypes.DashboardCardDotState +type DashboardWorkspace = DashboardSnapshotTypes.DashboardWorkspace + +export { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing' +export { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +export const AGENT_MAP_AGENT_RADIUS = 20 +export const AGENT_MAP_AGGREGATE_ZOOM = 1.15 +export const AGENT_MAP_RING_HEADER_HEIGHT = 40 + +const PROJECT_PADDING = 12 +const WORLD_MARGIN = 32 +const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2 + +export type AgentMapStatusCounts = Record + +export type AgentMapAgentNode = { + card: DashboardCard + x: number + y: number + radius: number + durationMinutes: number + status: DashboardCardDotState +} + +export type AgentMapWorktreeRing = { + id: string + parentId?: string + /** Layout-only parent chosen from agent spawn edges; does not imply workspace lineage. */ + clusterParentId?: string + worktreeId: string + executionHostId: DashboardCard['executionHostId'] + name: string + workspaceKind: NonNullable + x: number + y: number + radius: number + agents: AgentMapAgentNode[] + statusCounts: AgentMapStatusCounts + quiet: boolean +} + +export type AgentMapProjectRing = { + id: string + name: string + x: number + y: number + radius: number + worktrees: AgentMapWorktreeRing[] + agentCount: number +} + +export type AgentMapLayout = { + projects: AgentMapProjectRing[] + width: number + height: number + topologyKey: string +} + +export type AgentMapLayoutCache = { + topologyKey: string + geometry: AgentMapLayout + packingGeneration: number +} + +type LocalWorktree = Omit & { x: number; y: number } +type LocalProject = Omit & { + x: number + y: number + clusterParentId?: string + worktrees: LocalWorktree[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function agentMapTopologyKey( + cards: DashboardCard[], + workspaces: DashboardWorkspace[] = [] +): string { + return [ + ...cards.map((card) => `a:${agentMapCardTopologyIdentity(card)}`), + ...workspaces.map((workspace) => `w:${agentMapWorkspaceTopologyIdentity(workspace)}`) + ] + .sort(compareStable) + .join('|') +} + +export function shouldAggregateAgentMapWorktree( + worktree: AgentMapWorktreeRing, + zoom: number, + allowAggregation = true +): boolean { + return ( + allowAggregation && + zoom < AGENT_MAP_AGGREGATE_ZOOM && + worktree.quiet && + worktree.agents.length > 3 + ) +} + +function emptyStatusCounts(): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 } +} + +function worktreeRadius(agentCount: number): number { + return Math.max( + 52, + 24 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (AGENT_MAP_AGENT_RADIUS + 8) + ) +} + +function buildLocalWorktree( + id: string, + cards: DashboardCard[], + now: number, + workspace?: DashboardWorkspace +): LocalWorktree { + const lineageLayout = layoutAgentMapLineage(cards, AGENT_MAP_AGENT_RADIUS) + const contentRadius = lineageLayout?.radius ?? worktreeRadius(cards.length) + const radius = contentRadius + RING_CONTENT_OFFSET + const statusCounts = emptyStatusCounts() + for (const card of cards) { + statusCounts[agentMapNodeStatus(card)] += 1 + } + const executionHostId = workspace?.executionHostId ?? cards[0]?.executionHostId + const parentWorktreeId = workspace?.parentWorktreeId ?? cards[0]?.parentWorktreeId + return { + id, + parentId: parentWorktreeId + ? agentMapWorktreeIdentityFromParts(parentWorktreeId, executionHostId) + : undefined, + worktreeId: workspace?.worktreeId ?? cards[0]?.worktreeId ?? id, + executionHostId, + name: workspace?.worktreeName ?? cards[0]?.worktreeName ?? id, + workspaceKind: workspace?.workspaceKind ?? cards[0]?.workspaceKind ?? 'worktree', + x: 0, + y: 0, + radius, + agents: ( + lineageLayout?.agents.map(({ card, x, y }) => ({ + card, + x, + y, + radius: AGENT_MAP_AGENT_RADIUS, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + })) ?? + placeAgentMapAgents({ + worktreeId: id, + cards, + radius: contentRadius, + agentRadius: AGENT_MAP_AGENT_RADIUS, + now + }) + ).map((agent) => ({ ...agent, y: agent.y + RING_CONTENT_OFFSET })), + statusCounts, + quiet: statusCounts.idle === cards.length + } +} + +function buildLocalProject( + id: string, + cards: DashboardCard[], + workspaces: DashboardWorkspace[], + cardsByPaneKey: ReadonlyMap, + now: number +): LocalProject { + const byWorktree = new Map() + for (const card of cards) { + const identity = agentMapWorktreeIdentity(card) + const current = byWorktree.get(identity) + if (current) { + current.push(card) + } else { + byWorktree.set(identity, [card]) + } + } + const workspacesById = new Map( + workspaces.map((workspace) => [agentMapWorkspaceIdentity(workspace), workspace]) + ) + for (const workspaceId of workspacesById.keys()) { + if (!byWorktree.has(workspaceId)) { + byWorktree.set(workspaceId, []) + } + } + const positionedWorktrees = layoutAgentMapWorktreeLineage( + [...byWorktree.entries()] + .sort(([a], [b]) => compareStable(a, b)) + .map(([worktreeId, worktreeCards]) => ({ + ...buildLocalWorktree(worktreeId, worktreeCards, now, workspacesById.get(worktreeId)), + clusterParentId: selectAgentMapSpawnParentContainer( + worktreeCards, + cardsByPaneKey, + agentMapWorktreeIdentity + ) + })) + ) + const contentRadius = Math.max( + 84, + ...positionedWorktrees.map( + (worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius + PROJECT_PADDING + ) + ) + const worktrees = positionedWorktrees.map((worktree) => ({ + ...worktree, + y: worktree.y + RING_CONTENT_OFFSET + })) + return { + id, + name: cards[0]?.repoName ?? workspaces[0]?.repoName ?? id, + x: 0, + y: 0, + clusterParentId: selectAgentMapSpawnParentContainer( + cards, + cardsByPaneKey, + (card) => card.repoId + ), + radius: contentRadius + RING_CONTENT_OFFSET, + worktrees, + agentCount: cards.length + } +} + +export function deriveAgentMapLayout( + cards: DashboardCard[], + now: number, + workspaces: DashboardWorkspace[] = [] +): AgentMapLayout { + const topologyKey = agentMapTopologyKey(cards, workspaces) + if (cards.length === 0 && workspaces.length === 0) { + return { projects: [], width: 900, height: 560, topologyKey } + } + const byProject = new Map() + for (const card of cards) { + const current = byProject.get(card.repoId) ?? { cards: [], workspaces: [] } + current.cards.push(card) + byProject.set(card.repoId, current) + } + for (const workspace of workspaces) { + const current = byProject.get(workspace.repoId) ?? { cards: [], workspaces: [] } + current.workspaces.push(workspace) + byProject.set(workspace.repoId, current) + } + const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) + const localProjects = [...byProject.entries()] + .sort(([a], [b]) => compareStable(a, b)) + .map(([projectId, project]) => + buildLocalProject(projectId, project.cards, project.workspaces, cardsByPaneKey, now) + ) + const framed = placeAgentMapProjects(localProjects, 900, 560, WORLD_MARGIN) + const projects = framed.projects.map((project): AgentMapProjectRing => { + return { + ...project, + worktrees: project.worktrees.map((worktree) => ({ + ...worktree, + x: project.x + worktree.x, + y: project.y + worktree.y, + agents: worktree.agents.map((agent) => ({ + ...agent, + x: project.x + worktree.x + agent.x, + y: project.y + worktree.y + agent.y + })) + })) + } + }) + return { projects, width: framed.width, height: framed.height, topologyKey } +} + +export function updateAgentMapLayout( + cache: AgentMapLayoutCache | null, + cards: DashboardCard[], + now: number, + workspaces: DashboardWorkspace[] = [] +): { cache: AgentMapLayoutCache; layout: AgentMapLayout } { + const topologyKey = agentMapTopologyKey(cards, workspaces) + if (!cache || cache.topologyKey !== topologyKey) { + const geometry = deriveAgentMapLayout(cards, now, workspaces) + return { + cache: { + topologyKey, + geometry, + packingGeneration: (cache?.packingGeneration ?? 0) + 1 + }, + layout: geometry + } + } + const layout = refreshAgentMapMetadata(cache.geometry, cards, now) + return { cache, layout } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts new file mode 100644 index 00000000000..f5d3665c483 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts @@ -0,0 +1,224 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { packAgentMapWorktrees } from './agent-map-worktree-packing' + +const HORIZONTAL_GAP = 54 +const VERTICAL_GAP = 58 +const FAMILY_PADDING = 8 +const WORKTREE_PADDING = 6 +const COMPACT_FANOUT_THRESHOLD = 12 +const MAX_EXACT_LINEAGE_AGENTS = 256 + +export type AgentMapLineagePosition = { + card: DashboardCard + x: number + y: number +} + +type AgentMapAgentFamily = { + id: string + x: number + y: number + radius: number + agents: AgentMapLineagePosition[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function encloseFamily( + id: string, + agents: AgentMapLineagePosition[], + nodeRadius: number +): AgentMapAgentFamily { + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const agent of agents) { + left = Math.min(left, agent.x - nodeRadius) + right = Math.max(right, agent.x + nodeRadius) + top = Math.min(top, agent.y - nodeRadius) + bottom = Math.max(bottom, agent.y + nodeRadius) + } + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + let radius = 0 + for (const agent of agents) { + agent.x -= centerX + agent.y -= centerY + radius = Math.max(radius, Math.hypot(agent.x, agent.y) + nodeRadius + FAMILY_PADDING) + } + return { id, x: 0, y: 0, radius, agents } +} + +function buildCompactFanoutFamily( + root: DashboardCard, + children: DashboardCard[], + nodeRadius: number, + emitted: Set +): AgentMapAgentFamily { + const columns = Math.ceil(Math.sqrt(children.length)) + const width = (Math.min(columns, children.length) - 1) * HORIZONTAL_GAP + const agents: AgentMapLineagePosition[] = [{ card: root, x: 0, y: 0 }] + emitted.add(root.paneKey) + for (const [index, child] of children.entries()) { + emitted.add(child.paneKey) + agents.push({ + card: child, + x: (index % columns) * HORIZONTAL_GAP - width / 2, + y: (Math.floor(index / columns) + 1) * VERTICAL_GAP + }) + } + return encloseFamily(root.paneKey, agents, nodeRadius) +} + +function buildFamily( + root: DashboardCard, + childrenByParent: ReadonlyMap, + nodeRadius: number, + emitted: Set +): AgentMapAgentFamily { + const agents: AgentMapLineagePosition[] = [] + let leafIndex = 0 + const rootChildren = (childrenByParent.get(root.paneKey) ?? []).filter( + (child) => !emitted.has(child.paneKey) + ) + if ( + rootChildren.length >= COMPACT_FANOUT_THRESHOLD && + rootChildren.every((child) => (childrenByParent.get(child.paneKey) ?? []).length === 0) + ) { + return buildCompactFanoutFamily(root, rootChildren, nodeRadius, emitted) + } + + const placeSubtree = ( + card: DashboardCard, + depth: number, + ancestors: ReadonlySet + ): number => { + if (ancestors.has(card.paneKey) || emitted.has(card.paneKey)) { + return leafIndex++ * HORIZONTAL_GAP + } + emitted.add(card.paneKey) + const nextAncestors = new Set(ancestors) + nextAncestors.add(card.paneKey) + const children = (childrenByParent.get(card.paneKey) ?? []).filter( + (child) => !nextAncestors.has(child.paneKey) && !emitted.has(child.paneKey) + ) + const childXs = children.map((child) => placeSubtree(child, depth + 1, nextAncestors)) + const x = + childXs.length > 0 + ? (Math.min(...childXs) + Math.max(...childXs)) / 2 + : leafIndex++ * HORIZONTAL_GAP + agents.push({ card, x, y: depth * VERTICAL_GAP }) + return x + } + + placeSubtree(root, 0, new Set()) + return encloseFamily(root.paneKey, agents, nodeRadius) +} + +function layoutBoundedLineage( + sorted: DashboardCard[], + childrenByParent: ReadonlyMap, + childPaneKeys: ReadonlySet, + nodeRadius: number +): { agents: AgentMapLineagePosition[]; radius: number } { + const levels: DashboardCard[][] = [] + const emitted = new Set() + const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey)) + for (const seed of [...roots, ...sorted]) { + if (emitted.has(seed.paneKey)) { + continue + } + const stack = [{ card: seed, depth: 0 }] + while (stack.length > 0) { + const entry = stack.pop()! + if (emitted.has(entry.card.paneKey)) { + continue + } + emitted.add(entry.card.paneKey) + const level = levels[entry.depth] ?? [] + levels[entry.depth] = level + level.push(entry.card) + const children = childrenByParent.get(entry.card.paneKey) ?? [] + for (let index = children.length - 1; index >= 0; index -= 1) { + if (!emitted.has(children[index].paneKey)) { + stack.push({ card: children[index], depth: entry.depth + 1 }) + } + } + } + } + + const agents: AgentMapLineagePosition[] = [] + let rowIndex = 0 + for (const level of levels) { + const columns = Math.ceil(Math.sqrt(level.length)) + for (let rowStart = 0; rowStart < level.length; rowStart += columns) { + const row = level.slice(rowStart, rowStart + columns) + const width = (row.length - 1) * HORIZONTAL_GAP + for (const [index, card] of row.entries()) { + agents.push({ card, x: index * HORIZONTAL_GAP - width / 2, y: rowIndex * VERTICAL_GAP }) + } + rowIndex += 1 + } + } + const family = encloseFamily(sorted[0].paneKey, agents, nodeRadius) + family.agents.sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)) + return { agents: family.agents, radius: Math.max(52, family.radius + WORKTREE_PADDING) } +} + +export function layoutAgentMapLineage( + cards: DashboardCard[], + nodeRadius: number +): { agents: AgentMapLineagePosition[]; radius: number } | null { + const sorted = [...cards].sort((a, b) => compareStable(a.paneKey, b.paneKey)) + const cardsByPaneKey = new Map(sorted.map((card) => [card.paneKey, card])) + const childrenByParent = new Map() + const childPaneKeys = new Set() + + for (const card of sorted) { + const parentPaneKey = card.parentPaneKey + if (!parentPaneKey || parentPaneKey === card.paneKey || !cardsByPaneKey.has(parentPaneKey)) { + continue + } + childPaneKeys.add(card.paneKey) + childrenByParent.set(parentPaneKey, [...(childrenByParent.get(parentPaneKey) ?? []), card]) + } + if (childPaneKeys.size === 0) { + return null + } + if (sorted.length > MAX_EXACT_LINEAGE_AGENTS) { + return layoutBoundedLineage(sorted, childrenByParent, childPaneKeys, nodeRadius) + } + + const emitted = new Set() + const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey)) + const families: AgentMapAgentFamily[] = [] + for (const root of roots) { + if (!emitted.has(root.paneKey)) { + families.push(buildFamily(root, childrenByParent, nodeRadius, emitted)) + } + } + for (const card of sorted) { + if (!emitted.has(card.paneKey)) { + families.push(buildFamily(card, childrenByParent, nodeRadius, emitted)) + } + } + const packed = packAgentMapWorktrees(families) + return { + agents: packed + .flatMap((family) => + family.agents.map((agent) => ({ + ...agent, + x: family.x + agent.x, + y: family.y + agent.y + })) + ) + .sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)), + radius: Math.max( + 52, + ...packed.map((family) => Math.hypot(family.x, family.y) + family.radius + WORKTREE_PADDING) + ) + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts new file mode 100644 index 00000000000..d8c4baf6edc --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { deriveAgentMapLayout } from './agent-map-layout' +import { navigableAgentMapAgents } from './agent-map-navigation' + +const NOW = 2_000_000_000 + +function card(paneKey: string, worktreeId: string, idle: boolean): DashboardCard { + return { + paneKey, + ptyId: `pty-${paneKey}`, + agentType: 'codex', + bucket: idle ? 'idle' : 'working', + dotState: idle ? 'idle' : 'working', + task: '', + repoId: 'repo-1', + worktreeId, + tabId: `tab-${paneKey}`, + leafId: `leaf-${paneKey}`, + repoName: 'Orca', + worktreeName: worktreeId, + startedAt: NOW - 60_000, + finishedAt: idle ? NOW - 30_000 : null, + stateChangedAt: NOW - 30_000, + unseen: false + } +} + +describe('agent map keyboard navigation visibility', () => { + it('excludes every aggregated worktree and restores a selected quiet worktree', () => { + const quietCards = Array.from({ length: 5 }, (_, index) => + card(`quiet-${index}`, 'quiet-worktree', true) + ) + const active = card('active', 'active-worktree', false) + const layout = deriveAgentMapLayout([...quietCards, active], NOW) + + expect( + navigableAgentMapAgents(layout, 1, true, null).map((agent) => agent.card.paneKey) + ).toEqual(['active']) + expect(navigableAgentMapAgents(layout, 1, true, 'quiet-0')).toHaveLength(6) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts new file mode 100644 index 00000000000..ae3cfdbea2b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts @@ -0,0 +1,53 @@ +import type { AgentMapAgentNode, AgentMapLayout } from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' + +type Direction = { x: number; y: number } + +export function agentMapAgents(layout: AgentMapLayout): AgentMapAgentNode[] { + return layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => worktree.agents) + ) +} + +export function navigableAgentMapAgents( + layout: AgentMapLayout, + zoom: number, + allowAggregation: boolean, + selectedPaneKey: string | null +): AgentMapAgentNode[] { + return layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => { + const containsSelection = worktree.agents.some( + (agent) => agent.card.paneKey === selectedPaneKey + ) + return !containsSelection && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) + ? [] + : worktree.agents + }) + ) +} + +export function nextDirectionalAgent( + current: AgentMapAgentNode, + agents: AgentMapAgentNode[], + direction: Direction +): AgentMapAgentNode | null { + let best: { agent: AgentMapAgentNode; score: number } | null = null + for (const candidate of agents) { + if (candidate.card.paneKey === current.card.paneKey) { + continue + } + const dx = candidate.x - current.x + const dy = candidate.y - current.y + const forward = dx * direction.x + dy * direction.y + if (forward <= 0) { + continue + } + const sideways = Math.abs(dx * direction.y - dy * direction.x) + const score = forward + sideways * 2 + if (!best || score < best.score) { + best = { agent: candidate, score } + } + } + return best?.agent ?? null +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts new file mode 100644 index 00000000000..09532614d6d --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts @@ -0,0 +1,17 @@ +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardDotState +} from '../../../../shared/dashboard-snapshot' + +export function agentMapDurationMinutes(card: DashboardCard, now: number): number { + if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) { + return 0 + } + const end = card.finishedAt && card.finishedAt >= card.startedAt ? card.finishedAt : now + return Math.max(0, (end - card.startedAt) / 60_000) +} + +export function agentMapNodeStatus(card: DashboardCard): DashboardCardDotState { + return dashboardCardDisplayState(card) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts b/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts new file mode 100644 index 00000000000..83aefde2f06 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts @@ -0,0 +1,94 @@ +export const AGENT_MAP_WORKTREE_GAP = 8 +export const AGENT_MAP_PACKING_SCORE_TOLERANCE = 0.001 + +const PACKING_GRID_SIZE = 128 + +export type AgentMapPackableCircle = { + id: string + x: number + y: number + radius: number +} + +type PackingSpatialGrid = { + cells: Map> + cellSize: number +} + +export type AgentMapPackingSpatialIndex = Map + +function packingGridLevel(radius: number): number { + return Math.max( + 0, + Math.ceil(Math.log2((radius * 2 + AGENT_MAP_WORKTREE_GAP) / PACKING_GRID_SIZE)) + ) +} + +export function addAgentMapPackingCircle( + index: AgentMapPackingSpatialIndex, + circle: AgentMapPackableCircle +): void { + const level = packingGridLevel(circle.radius) + let grid = index.get(level) + if (!grid) { + grid = { cells: new Map(), cellSize: PACKING_GRID_SIZE * 2 ** level } + index.set(level, grid) + } + const left = Math.floor((circle.x - circle.radius) / grid.cellSize) + const right = Math.floor((circle.x + circle.radius) / grid.cellSize) + const top = Math.floor((circle.y - circle.radius) / grid.cellSize) + const bottom = Math.floor((circle.y + circle.radius) / grid.cellSize) + for (let x = left; x <= right; x += 1) { + let column = grid.cells.get(x) + if (!column) { + column = new Map() + grid.cells.set(x, column) + } + for (let y = top; y <= bottom; y += 1) { + const cell = column.get(y) + if (cell) { + cell.push(circle) + } else { + column.set(y, [circle]) + } + } + } +} + +export function agentMapPackingCircleOverlaps( + candidate: Pick, + index: AgentMapPackingSpatialIndex +): boolean { + const searchRadius = candidate.radius + AGENT_MAP_WORKTREE_GAP + const checked = new Set() + for (const grid of index.values()) { + const left = Math.floor((candidate.x - searchRadius) / grid.cellSize) + const right = Math.floor((candidate.x + searchRadius) / grid.cellSize) + const top = Math.floor((candidate.y - searchRadius) / grid.cellSize) + const bottom = Math.floor((candidate.y + searchRadius) / grid.cellSize) + for (let x = left; x <= right; x += 1) { + const column = grid.cells.get(x) + if (!column) { + continue + } + for (let y = top; y <= bottom; y += 1) { + for (const circle of column.get(y) ?? []) { + if (checked.has(circle)) { + continue + } + checked.add(circle) + if ( + Math.hypot(candidate.x - circle.x, candidate.y - circle.y) < + candidate.radius + + circle.radius + + AGENT_MAP_WORKTREE_GAP - + AGENT_MAP_PACKING_SCORE_TOLERANCE + ) { + return true + } + } + } + } + } + return false +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts b/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts new file mode 100644 index 00000000000..413102bb3f0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts @@ -0,0 +1,50 @@ +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +const PROJECT_GAP = 32 + +type ProjectCircle = { + id: string + x: number + y: number + radius: number + clusterParentId?: string +} + +function placeUnlinkedProjects(projects: T[]): T[] { + let cursorX = 0 + return projects.map((project) => { + const positioned = { ...project, x: cursorX + project.radius, y: 0 } + cursorX += project.radius * 2 + PROJECT_GAP + return positioned + }) +} + +export function placeAgentMapProjects( + projects: T[], + minimumWidth: number, + minimumHeight: number, + worldMargin: number +): { projects: T[]; width: number; height: number } { + const positioned = projects.some((project) => project.clusterParentId) + ? layoutAgentMapWorktreeLineage(projects) + : placeUnlinkedProjects(projects) + const left = Math.min(...positioned.map((project) => project.x - project.radius)) + const right = Math.max(...positioned.map((project) => project.x + project.radius)) + const top = Math.min(...positioned.map((project) => project.y - project.radius)) + const bottom = Math.max(...positioned.map((project) => project.y + project.radius)) + const naturalWidth = right - left + worldMargin * 2 + const naturalHeight = bottom - top + worldMargin * 2 + const width = Math.max(minimumWidth, naturalWidth) + const height = Math.max(minimumHeight, naturalHeight) + const offsetX = worldMargin - left + (width - naturalWidth) / 2 + const offsetY = worldMargin - top + (height - naturalHeight) / 2 + return { + projects: positioned.map((project) => ({ + ...project, + x: project.x + offsetX, + y: project.y + offsetY + })), + width, + height + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts new file mode 100644 index 00000000000..a341fdd1767 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts @@ -0,0 +1,27 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function selectAgentMapSpawnParentContainer( + cards: readonly DashboardCard[], + cardsByPaneKey: ReadonlyMap, + containerIdentity: (card: DashboardCard) => string +): string | undefined { + const ownContainerId = cards[0] ? containerIdentity(cards[0]) : undefined + const linkCounts = new Map() + for (const card of cards) { + const parent = card.parentPaneKey ? cardsByPaneKey.get(card.parentPaneKey) : undefined + const parentContainerId = parent ? containerIdentity(parent) : undefined + if (!parentContainerId || parentContainerId === ownContainerId) { + continue + } + linkCounts.set(parentContainerId, (linkCounts.get(parentContainerId) ?? 0) + 1) + } + return [...linkCounts] + .sort(([leftId, leftCount], [rightId, rightCount]) => + rightCount !== leftCount ? rightCount - leftCount : compareStable(leftId, rightId) + ) + .at(0)?.[0] +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts new file mode 100644 index 00000000000..6fe03f6f585 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts @@ -0,0 +1,56 @@ +export type AgentMapViewport = { + center: { x: number; y: number } + zoom: number +} + +type ViewportTransitionOptions = { + from: AgentMapViewport + to: AgentMapViewport + durationMs: number + onFrame: (viewport: AgentMapViewport) => void + onComplete?: () => void +} + +function interpolate(from: number, to: number, progress: number): number { + return from + (to - from) * progress +} + +export function startAgentMapViewportTransition({ + from, + to, + durationMs, + onFrame, + onComplete +}: ViewportTransitionOptions): () => void { + let frameId: number | null = null + let startedAt: number | null = null + let cancelled = false + const tick = (now: number): void => { + if (cancelled) { + return + } + startedAt ??= now + const progress = Math.min(1, (now - startedAt) / durationMs) + const eased = 1 - (1 - progress) ** 3 + onFrame({ + center: { + x: interpolate(from.center.x, to.center.x, eased), + y: interpolate(from.center.y, to.center.y, eased) + }, + zoom: interpolate(from.zoom, to.zoom, eased) + }) + if (progress < 1) { + frameId = requestAnimationFrame(tick) + } else { + frameId = null + onComplete?.() + } + } + frameId = requestAnimationFrame(tick) + return () => { + cancelled = true + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts new file mode 100644 index 00000000000..5ceaf4e93eb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts @@ -0,0 +1,29 @@ +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' + +export function agentMapCardTopologyIdentity(card: DashboardCard): string { + const parentPaneKey = card.parentPaneKey ?? '' + const parentWorktreeId = card.parentWorktreeId ?? '' + const executionHostId = card.executionHostId ?? '' + return `${card.repoId.length}:${card.repoId}${card.worktreeId.length}:${card.worktreeId}${executionHostId.length}:${executionHostId}${card.paneKey.length}:${card.paneKey}${parentPaneKey.length}:${parentPaneKey}${parentWorktreeId.length}:${parentWorktreeId}` +} + +export function agentMapWorkspaceTopologyIdentity(workspace: DashboardWorkspace): string { + const parentWorktreeId = workspace.parentWorktreeId ?? '' + return `${workspace.repoId.length}:${workspace.repoId}${workspace.worktreeId.length}:${workspace.worktreeId}${workspace.executionHostId.length}:${workspace.executionHostId}${parentWorktreeId.length}:${parentWorktreeId}` +} + +export function agentMapWorktreeIdentityFromParts( + worktreeId: string, + executionHostId: DashboardCard['executionHostId'] +): string { + const hostId = executionHostId ?? '' + return `${worktreeId.length}:${worktreeId}${hostId.length}:${hostId}` +} + +export function agentMapWorktreeIdentity(card: DashboardCard): string { + return agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId) +} + +export function agentMapWorkspaceIdentity(workspace: DashboardWorkspace): string { + return agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts new file mode 100644 index 00000000000..1a9593af373 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { EMPTY_DASHBOARD_FILTERS } from './agent-board-filtering' +import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility' + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: null, + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: '', + repoId: 'repo-1', + worktreeId: 'occupied', + tabId: 'tab-1', + leafId: null, + repoName: 'Orca', + worktreeName: 'Occupied', + executionHostId: 'local', + startedAt: 0, + finishedAt: null, + stateChangedAt: 0, + unseen: false, + ...overrides + } +} + +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'repo-1', + worktreeId: 'empty', + repoName: 'Orca', + worktreeName: 'Empty child', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + workspaceStatusId: 'planned', + ...overrides + } +} + +describe('agent map workspace visibility', () => { + it('returns only workspaces that have no dashboard card on the same host', () => { + const result = selectAgentlessMapWorkspaces({ + cards: [card()], + workspaces: [ + workspace({ worktreeId: 'occupied', worktreeName: 'Occupied' }), + workspace(), + workspace({ + worktreeId: 'occupied', + worktreeName: 'Remote twin', + hostKind: 'ssh', + executionHostId: 'ssh:build-box' + }) + ], + query: '', + filters: EMPTY_DASHBOARD_FILTERS + }) + + expect(result.map((item) => item.worktreeName)).toEqual(['Empty child', 'Remote twin']) + }) + + it('applies search and workspace filters to agentless workspaces', () => { + const result = selectAgentlessMapWorkspaces({ + cards: [], + workspaces: [ + workspace({ worktreeName: 'Listener security', review: { number: 42, state: 'open' } }), + workspace({ worktreeId: 'other', worktreeName: 'Unrelated', workspaceStatusId: 'active' }) + ], + query: 'listener', + filters: { + projects: ['repo-1'], + workspaceStatuses: ['planned'], + reviewStates: ['open'] + } + }) + + expect(result.map((item) => item.worktreeName)).toEqual(['Listener security']) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts new file mode 100644 index 00000000000..311dd95e2f6 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts @@ -0,0 +1,25 @@ +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { filterDashboardWorkspaces, type DashboardFilters } from './agent-board-filtering' +import { agentMapWorktreeIdentityFromParts } from './agent-map-workspace-identity' + +export function selectAgentlessMapWorkspaces({ + cards, + workspaces, + query, + filters +}: { + cards: DashboardCard[] + workspaces: DashboardWorkspace[] + query: string + filters: DashboardFilters +}): DashboardWorkspace[] { + const occupiedWorkspaceIds = new Set( + cards.map((card) => agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId)) + ) + return filterDashboardWorkspaces(workspaces, query, filters).filter( + (workspace) => + !occupiedWorkspaceIds.has( + agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId) + ) + ) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts new file mode 100644 index 00000000000..c02eb68ed27 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import type { AgentMapStatusCounts } from './agent-map-layout' +import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status' + +function counts(overrides: Partial = {}): AgentMapStatusCounts { + return { blocked: 0, waiting: 0, working: 0, done: 0, idle: 0, ...overrides } +} + +describe('agentMapWorktreeActiveStatus', () => { + it('prioritizes attention over working', () => { + expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1 }))).toBe('waiting') + expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1, blocked: 1 }))).toBe( + 'blocked' + ) + }) + + it('uses working only when no agent needs attention', () => { + expect(agentMapWorktreeActiveStatus(counts({ working: 1, done: 2 }))).toBe('working') + expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts new file mode 100644 index 00000000000..925a80940e5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts @@ -0,0 +1,15 @@ +import type { AgentMapStatusCounts } from './agent-map-layout' + +export type AgentMapWorktreeActiveStatus = 'blocked' | 'waiting' | 'working' + +export function agentMapWorktreeActiveStatus( + counts: AgentMapStatusCounts +): AgentMapWorktreeActiveStatus | null { + if (counts.blocked > 0) { + return 'blocked' + } + if (counts.waiting > 0) { + return 'waiting' + } + return counts.working > 0 ? 'working' : null +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts new file mode 100644 index 00000000000..8bfb9fc28d4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing' +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +function buildChain(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `worktree-${index.toString().padStart(4, '0')}`, + parentId: index === 0 ? undefined : `worktree-${(index - 1).toString().padStart(4, '0')}`, + radius: 32, + x: 0, + y: 0 + })) +} + +function buildComb(spineCount: number) { + const worktrees: ReturnType = [] + for (let index = 0; index < spineCount; index += 1) { + const suffix = index.toString().padStart(4, '0') + worktrees.push({ + id: `spine-${suffix}`, + parentId: index === 0 ? undefined : `spine-${(index - 1).toString().padStart(4, '0')}`, + radius: 32, + x: 0, + y: 0 + }) + if (index < spineCount - 1) { + worktrees.push({ + id: `leaf-${suffix}`, + parentId: `spine-${suffix}`, + radius: 24, + x: 0, + y: 0 + }) + } + } + return worktrees +} + +function layoutWithNumericMapSetCount(worktrees: ReturnType) { + const set = Map.prototype.set + let numericMapSets = 0 + Map.prototype.set = function (this: Map, key: unknown, value: unknown) { + if (typeof key === 'number') { + numericMapSets += 1 + } + return Reflect.apply(set, this, [key, value]) + } as typeof Map.prototype.set + try { + return { layout: layoutAgentMapWorktreeLineage(worktrees), numericMapSets } + } finally { + Map.prototype.set = set + } +} + +function layoutWithWorktreePushCount(count: number) { + const push = Array.prototype.push + let worktreePushes = 0 + Array.prototype.push = function (...items: unknown[]): number { + worktreePushes += items.filter( + (item) => + typeof item === 'object' && + item !== null && + 'id' in item && + typeof item.id === 'string' && + item.id.startsWith('worktree-') + ).length + return Reflect.apply(push, this, items) + } + try { + return { + layout: layoutAgentMapWorktreeLineage(buildChain(count)), + worktreePushes + } + } finally { + Array.prototype.push = push + } +} + +describe('layoutAgentMapWorktreeLineage', () => { + it('keeps branched and linear family coordinates deterministic', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'root', x: 0, y: 0, radius: 40 }, + { id: 'child-a', parentId: 'root', x: 0, y: 0, radius: 30 }, + { id: 'grandchild-a', parentId: 'child-a', x: 0, y: 0, radius: 25 }, + { id: 'child-b', parentId: 'root', x: 0, y: 0, radius: 45 }, + { id: 'second-root', x: 0, y: 0, radius: 35 }, + { id: 'second-child', parentId: 'second-root', x: 0, y: 0, radius: 20 } + ]) + + expect(layout).toEqual([ + { + id: 'child-a', + parentId: 'root', + radius: 30, + x: -7.740689238053122, + y: 42.47172405948656 + }, + { + id: 'child-b', + parentId: 'root', + radius: 45, + x: 69.93546272327787, + y: 175.54837055839306 + }, + { + id: 'grandchild-a', + parentId: 'child-a', + radius: 25, + x: -7.740689238053122, + y: 125.47172405948656 + }, + { id: 'root', radius: 40, x: 19.097386742612372, y: -55.52827594051344 }, + { + id: 'second-child', + parentId: 'second-root', + radius: 20, + x: -112.9872940755618, + y: -73.65876088400047 + }, + { + id: 'second-root', + radius: 35, + x: -112.9872940755618, + y: -156.65876088400046 + } + ]) + }) + + it('flattens a 1,000-worktree lineage once', () => { + const { layout, worktreePushes } = layoutWithWorktreePushCount(1_000) + + expect(layout).toHaveLength(1_000) + expect(worktreePushes).toBeLessThan(5_000) + for (let index = 1; index < layout.length; index += 1) { + expect(layout[index].y).toBeGreaterThan(layout[index - 1].y) + expect(layout[index].y - layout[index - 1].y).toBeGreaterThanOrEqual( + layout[index].radius + layout[index - 1].radius + AGENT_MAP_WORKTREE_GAP + ) + } + }) + + it.each([ + [399, 200], + [999, 500] + ])('avoids spatial-grid expansion for a %i-worktree comb', (expectedCount, spineCount) => { + const worktrees = buildComb(spineCount) + const { layout, numericMapSets } = layoutWithNumericMapSetCount(worktrees) + + expect(layout).toHaveLength(expectedCount) + expect(numericMapSets).toBeLessThan(10) + expect(layoutAgentMapWorktreeLineage(worktrees)).toEqual(layout) + }) + + it('keeps a deeply branched lineage finite without recursive stack growth', () => { + const worktrees = buildComb(2_500) + const layout = layoutAgentMapWorktreeLineage(worktrees) + const byId = new Map(layout.map((worktree) => [worktree.id, worktree])) + + expect(layout).toHaveLength(4_999) + expect( + layout.every( + (worktree) => + Number.isFinite(worktree.x) && + Number.isFinite(worktree.y) && + Math.abs(worktree.x) < 1_000_000 && + Math.abs(worktree.y) < 1_000_000 + ) + ).toBe(true) + for (const worktree of layout) { + if (worktree.parentId) { + expect(worktree.y).toBeGreaterThan(byId.get(worktree.parentId)!.y) + } + } + }) + + it('wraps very large worktree fanout without overlap', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'parent', x: 0, y: 0, radius: 32 }, + ...Array.from({ length: 300 }, (_, index) => ({ + id: `child-${index}`, + parentId: 'parent', + x: 0, + y: 0, + radius: 24 + })) + ]) + const parent = layout.find((worktree) => worktree.id === 'parent')! + const children = layout.filter( + (worktree) => 'parentId' in worktree && worktree.parentId === 'parent' + ) + let minimumGap = Number.POSITIVE_INFINITY + + expect(children.every((child) => child.y > parent.y)).toBe(true) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(child.x - other.x, child.y - other.y) - child.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP) + }) + + it('packs high-fanout spawn clusters without forcing every workspace below the coordinator', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'parent', x: 0, y: 0, radius: 32 }, + ...Array.from({ length: 13 }, (_, index) => ({ + id: `child-${index.toString().padStart(2, '0')}`, + clusterParentId: 'parent', + x: 0, + y: 0, + radius: 24 + })) + ]) + const parent = layout.find((worktree) => worktree.id === 'parent')! + const children = layout.filter( + (worktree) => 'clusterParentId' in worktree && worktree.clusterParentId === 'parent' + ) + + expect(children.some((child) => child.y <= parent.y)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts new file mode 100644 index 00000000000..1a6e2875b65 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts @@ -0,0 +1,279 @@ +import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing' + +const LINEAGE_VERTICAL_GAP = 28 +const MAX_HIERARCHICAL_CLUSTER_FANOUT = 12 +const MAX_EXACT_LINEAGE_WORKTREES = 256 + +type LineageWorktree = { + id: string + parentId?: string + clusterParentId?: string + x: number + y: number + radius: number +} + +type WorktreeFamily = { + id: string + x: number + y: number + radius: number + worktrees: T[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function encloseFamily(id: string, worktrees: T[]): WorktreeFamily { + const left = Math.min(...worktrees.map((worktree) => worktree.x - worktree.radius)) + const right = Math.max(...worktrees.map((worktree) => worktree.x + worktree.radius)) + const top = Math.min(...worktrees.map((worktree) => worktree.y - worktree.radius)) + const bottom = Math.max(...worktrees.map((worktree) => worktree.y + worktree.radius)) + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + for (const worktree of worktrees) { + worktree.x -= centerX + worktree.y -= centerY + } + return { + id, + x: 0, + y: 0, + radius: Math.max( + ...worktrees.map((worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius) + ), + worktrees + } +} + +function buildFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: Set, + ancestors: ReadonlySet +): WorktreeFamily { + emitted.add(root.id) + const nextAncestors = new Set(ancestors) + nextAncestors.add(root.id) + const children = (childrenByParent.get(root.id) ?? []).filter( + (child) => !nextAncestors.has(child.id) && !emitted.has(child.id) + ) + if (children.length === 0) { + return { + id: root.id, + x: 0, + y: 0, + radius: root.radius, + worktrees: [{ ...root, x: 0, y: 0 }] + } + } + + const childFamilies = packAgentMapWorktrees( + children.map((child) => buildExactFamily(child, childrenByParent, emitted)) + ) + const childLeft = Math.min(...childFamilies.map((family) => family.x - family.radius)) + const childRight = Math.max(...childFamilies.map((family) => family.x + family.radius)) + const childTop = Math.min(...childFamilies.map((family) => family.y - family.radius)) + const childOffsetX = -(childLeft + childRight) / 2 + const childOffsetY = root.radius + LINEAGE_VERTICAL_GAP - childTop + const worktrees = [{ ...root, x: 0, y: 0 }] + for (const family of childFamilies) { + for (const worktree of family.worktrees) { + worktrees.push({ + ...worktree, + x: worktree.x + family.x + childOffsetX, + y: worktree.y + family.y + childOffsetY + }) + } + } + return encloseFamily(root.id, worktrees) +} + +function collectLinearFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: ReadonlySet +): T[] | null { + const worktrees: T[] = [] + const ancestors = new Set() + let current: T | undefined = root + while (current) { + worktrees.push(current) + ancestors.add(current.id) + const children = (childrenByParent.get(current.id) ?? []).filter( + (child) => !ancestors.has(child.id) && !emitted.has(child.id) + ) + if (children.length > 1) { + return null + } + current = children[0] + } + return worktrees +} + +function buildLinearFamily(worktrees: T[]): WorktreeFamily { + const positioned = worktrees.map((worktree) => ({ ...worktree, x: 0, y: 0 })) + let radius = worktrees.at(-1)?.radius ?? 0 + for (let index = worktrees.length - 2; index >= 0; index -= 1) { + positioned[index].y = -(radius + LINEAGE_VERTICAL_GAP / 2) + radius += worktrees[index].radius + LINEAGE_VERTICAL_GAP / 2 + } + let familyCenterY = 0 + for (let index = 0; index < positioned.length; index += 1) { + positioned[index].y += familyCenterY + familyCenterY += worktrees[index].radius + LINEAGE_VERTICAL_GAP / 2 + } + return { id: worktrees[0].id, x: 0, y: 0, radius, worktrees: positioned } +} + +function buildExactFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: Set +): WorktreeFamily { + const linearFamily = collectLinearFamily(root, childrenByParent, emitted) + if (!linearFamily) { + return buildFamily(root, childrenByParent, emitted, new Set()) + } + for (const worktree of linearFamily) { + emitted.add(worktree.id) + } + return buildLinearFamily(linearFamily) +} + +function layoutBoundedLineage( + sorted: T[], + childrenByParent: ReadonlyMap, + childIds: ReadonlySet +): T[] { + const levels: T[][] = [] + const emitted = new Set() + const roots = sorted.filter((worktree) => !childIds.has(worktree.id)) + + for (const seed of [...roots, ...sorted]) { + if (emitted.has(seed.id)) { + continue + } + const stack = [{ depth: 0, worktree: seed }] + while (stack.length > 0) { + const entry = stack.pop()! + if (emitted.has(entry.worktree.id)) { + continue + } + emitted.add(entry.worktree.id) + const level = levels[entry.depth] ?? [] + levels[entry.depth] = level + level.push(entry.worktree) + const children = childrenByParent.get(entry.worktree.id) ?? [] + for (let index = children.length - 1; index >= 0; index -= 1) { + if (!emitted.has(children[index].id)) { + stack.push({ depth: entry.depth + 1, worktree: children[index] }) + } + } + } + } + + const positioned: T[] = [] + let y = 0 + let previousMaxRadius = 0 + let hasPositionedRow = false + for (const level of levels) { + const columns = Math.ceil(Math.sqrt(level.length)) + for (let rowStart = 0; rowStart < level.length; rowStart += columns) { + const row = level.slice(rowStart, rowStart + columns) + let maxRadius = 0 + let width = -AGENT_MAP_WORKTREE_GAP + for (const worktree of row) { + maxRadius = Math.max(maxRadius, worktree.radius) + width += worktree.radius * 2 + AGENT_MAP_WORKTREE_GAP + } + if (hasPositionedRow) { + y += previousMaxRadius + maxRadius + LINEAGE_VERTICAL_GAP + } + let x = -width / 2 + for (const worktree of row) { + positioned.push({ ...worktree, x: x + worktree.radius, y }) + x += worktree.radius * 2 + AGENT_MAP_WORKTREE_GAP + } + previousMaxRadius = maxRadius + hasPositionedRow = true + } + } + + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const worktree of positioned) { + left = Math.min(left, worktree.x - worktree.radius) + right = Math.max(right, worktree.x + worktree.radius) + top = Math.min(top, worktree.y - worktree.radius) + bottom = Math.max(bottom, worktree.y + worktree.radius) + } + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + return positioned + .map((worktree) => ({ ...worktree, x: worktree.x - centerX, y: worktree.y - centerY })) + .sort((a, b) => compareStable(a.id, b.id)) +} + +export function layoutAgentMapWorktreeLineage(worktrees: T[]): T[] { + const sorted = [...worktrees].sort((a, b) => compareStable(a.id, b.id)) + const worktreesById = new Map(sorted.map((worktree) => [worktree.id, worktree])) + const clusterChildCounts = new Map() + for (const worktree of sorted) { + if (worktree.clusterParentId && worktreesById.has(worktree.clusterParentId)) { + clusterChildCounts.set( + worktree.clusterParentId, + (clusterChildCounts.get(worktree.clusterParentId) ?? 0) + 1 + ) + } + } + const childrenByParent = new Map() + const childIds = new Set() + for (const worktree of sorted) { + const clusterParentId = worktree.clusterParentId + const parentId = + clusterParentId && + (clusterChildCounts.get(clusterParentId) ?? 0) <= MAX_HIERARCHICAL_CLUSTER_FANOUT + ? clusterParentId + : worktree.parentId + if (!parentId || parentId === worktree.id || !worktreesById.has(parentId)) { + continue + } + childIds.add(worktree.id) + const siblings = childrenByParent.get(parentId) + if (siblings) { + siblings.push(worktree) + } else { + childrenByParent.set(parentId, [worktree]) + } + } + if (sorted.length > MAX_EXACT_LINEAGE_WORKTREES) { + return layoutBoundedLineage(sorted, childrenByParent, childIds) + } + + const emitted = new Set() + const families: WorktreeFamily[] = [] + for (const root of sorted.filter((worktree) => !childIds.has(worktree.id))) { + if (!emitted.has(root.id)) { + families.push(buildExactFamily(root, childrenByParent, emitted)) + } + } + for (const worktree of sorted) { + if (!emitted.has(worktree.id)) { + families.push(buildExactFamily(worktree, childrenByParent, emitted)) + } + } + + return packAgentMapWorktrees(families) + .flatMap((family) => + family.worktrees.map((worktree) => ({ + ...worktree, + x: worktree.x + family.x, + y: worktree.y + family.y + })) + ) + .sort((a, b) => compareStable(a.id, b.id)) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts new file mode 100644 index 00000000000..a07490dab92 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing' + +function circles(count = 80): { id: string; x: number; y: number; radius: number }[] { + return Array.from({ length: count }, (_, index) => ({ + id: `worktree-${index.toString().padStart(2, '0')}`, + x: 0, + y: 0, + radius: 28 + (index % 7) * 13 + })) +} + +function measuredCircles(count = 80): { + worktrees: ReturnType + coordinateReads: () => number +} { + let reads = 0 + const worktrees = circles(count).map(({ id, radius }) => { + let x = 0 + let y = 0 + return { + id, + radius, + get x() { + reads += 1 + return x + }, + set x(value: number) { + x = value + }, + get y() { + reads += 1 + return y + }, + set y(value: number) { + y = value + } + } + }) + return { worktrees, coordinateReads: () => reads } +} + +describe('packAgentMapWorktrees', () => { + it('keeps variable-radius rings deterministic and non-overlapping', () => { + const first = packAgentMapWorktrees(circles()) + const second = packAgentMapWorktrees(circles()) + + expect(second).toEqual(first) + for (const [index, worktree] of first.entries()) { + for (const other of first.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('indexes rings that span multiple positive and negative grid cells', () => { + const packed = packAgentMapWorktrees( + [380, 260, 170, 145, 90].map((radius, index) => ({ + id: `large-${index}`, + x: 0, + y: 0, + radius + })) + ) + + expect(packed.some((worktree) => worktree.x < 0 || worktree.y < 0)).toBe(true) + for (const [index, worktree] of packed.entries()) { + for (const other of packed.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('keeps capped large-map packing deterministic and compact', () => { + const first = packAgentMapWorktrees(circles(300)) + const second = packAgentMapWorktrees(circles(300)) + + expect(second).toEqual(first) + expect( + Math.max(...first.map((worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius)) + ).toBeLessThan(1_500) + let minimumGap = Number.POSITIVE_INFINITY + for (const [index, worktree] of first.entries()) { + for (const other of first.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(worktree.x - other.x, worktree.y - other.y) - worktree.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP - 0.001) + }) + + it('bounds deterministic coordinate checks for larger maps', () => { + const { worktrees, coordinateReads } = measuredCircles(300) + + packAgentMapWorktrees(worktrees) + + expect(coordinateReads()).toBeLessThan(13_200_000) + }) + + it('bounds packing work for a thousand rings', () => { + const { worktrees, coordinateReads } = measuredCircles(1_000) + const packed = packAgentMapWorktrees(worktrees) + const positions = packed.map(({ id, x, y, radius }) => ({ id, x, y, radius })) + + expect(packed).toHaveLength(1_000) + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(coordinateReads()).toBeLessThan(10_000_000) + expect(packAgentMapWorktrees(circles(1_000))).toEqual(positions) + let minimumGap = Number.POSITIVE_INFINITY + for (const [index, worktree] of positions.entries()) { + for (const other of positions.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(worktree.x - other.x, worktree.y - other.y) - worktree.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP - 0.001) + }) + + it('bounds packing work when one ring dwarfs the rest', () => { + const { worktrees, coordinateReads } = measuredCircles(1_000) + worktrees[0].radius = 50_000_000 + const packed = packAgentMapWorktrees(worktrees) + + expect(packed).toHaveLength(1_000) + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(coordinateReads()).toBeLessThan(10_000_000) + }) + + it('keeps the spatial index bounded for very large rings', () => { + const set = Map.prototype.set + let numericMapSets = 0 + Map.prototype.set = function (this: Map, key: unknown, value: unknown) { + if (typeof key === 'number') { + numericMapSets += 1 + } + return Reflect.apply(set, this, [key, value]) + } as typeof Map.prototype.set + try { + const packed = packAgentMapWorktrees( + Array.from({ length: 5 }, (_, index) => ({ + id: `huge-${index}`, + x: 0, + y: 0, + radius: 50_000_000 - index * 1_000_000 + })) + ) + + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(numericMapSets).toBeLessThan(100) + } finally { + Map.prototype.set = set + } + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts new file mode 100644 index 00000000000..4dab5ac2346 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts @@ -0,0 +1,267 @@ +import { + addAgentMapPackingCircle, + agentMapPackingCircleOverlaps, + AGENT_MAP_PACKING_SCORE_TOLERANCE, + AGENT_MAP_WORKTREE_GAP, + type AgentMapPackableCircle, + type AgentMapPackingSpatialIndex +} from './agent-map-packing-spatial-index' + +export { AGENT_MAP_WORKTREE_GAP } from './agent-map-packing-spatial-index' + +const PACKING_ANGLE_STEPS = 72 +const MAX_PACKING_CANDIDATE_ANCHORS = 128 +const MAX_DIRECT_OVERLAP_WORKTREES = 4 +const LARGE_PACKING_THRESHOLD = 256 +const VERY_LARGE_PACKING_THRESHOLD = 512 +const SCORE_TOLERANCE = AGENT_MAP_PACKING_SCORE_TOLERANCE +const CENTER_DIRECTIONS = [ + [-1, -1], + [0, -1], + [1, -1], + [-1, 0], + [1, 0], + [-1, 1], + [0, 1], + [1, 1] +] as const + +type PackableWorktree = AgentMapPackableCircle + +type PackingCandidate = { + x: number + y: number + enclosingRadius: number + distanceFromCenter: number + neighborDistance?: number +} + +type PackingSearchBudget = { + angleSteps: number + candidateAnchors: number +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function hashFraction(value: string): number { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0) / 0xffffffff +} + +function placedWorktreesOverlap( + candidate: Pick, + placed: PackableWorktree[] +): boolean { + return placed.some( + (worktree) => + Math.hypot(candidate.x - worktree.x, candidate.y - worktree.y) < + candidate.radius + worktree.radius + AGENT_MAP_WORKTREE_GAP - SCORE_TOLERANCE + ) +} + +function comparePackingScores( + a: PackingCandidate, + b: PackingCandidate, + placed: PackableWorktree[] +): number { + for (const key of ['enclosingRadius', 'distanceFromCenter'] as const) { + if (Math.abs(a[key] - b[key]) > SCORE_TOLERANCE) { + return a[key] - b[key] + } + } + a.neighborDistance ??= placed.reduce( + (sum, other) => sum + Math.hypot(a.x - other.x, a.y - other.y), + 0 + ) + b.neighborDistance ??= placed.reduce( + (sum, other) => sum + Math.hypot(b.x - other.x, b.y - other.y), + 0 + ) + return Math.abs(a.neighborDistance - b.neighborDistance) > SCORE_TOLERANCE + ? a.neighborDistance - b.neighborDistance + : 0 +} + +function compareBoundaryAnchors(a: PackableWorktree, b: PackableWorktree): number { + return ( + Math.hypot(b.x, b.y) + b.radius - (Math.hypot(a.x, a.y) + a.radius) || compareStable(a.id, b.id) + ) +} + +function addBoundaryAnchor( + boundaryAnchors: PackableWorktree[], + worktree: PackableWorktree, + maxAnchors: number +): void { + let low = 0 + let high = boundaryAnchors.length + while (low < high) { + const middle = (low + high) >>> 1 + if (compareBoundaryAnchors(worktree, boundaryAnchors[middle]) < 0) { + high = middle + } else { + low = middle + 1 + } + } + boundaryAnchors.splice(low, 0, worktree) + if (boundaryAnchors.length > maxAnchors) { + boundaryAnchors.pop() + } +} + +function placePackedWorktree( + worktree: PackableWorktree, + placed: PackableWorktree[], + boundaryAnchors: PackableWorktree[], + spatialIndex: AgentMapPackingSpatialIndex | null, + currentRadius: number, + searchBudget: PackingSearchBudget +): void { + let best: PackingCandidate | undefined + + const anchors = placed.length <= searchBudget.candidateAnchors ? placed : boundaryAnchors + const scoreNeighbors = + searchBudget.candidateAnchors === MAX_PACKING_CANDIDATE_ANCHORS ? placed : anchors + for (const anchor of anchors) { + const orbit = anchor.radius + worktree.radius + AGENT_MAP_WORKTREE_GAP + const angleOffset = hashFraction(`${worktree.id}:${anchor.id}`) * Math.PI * 2 + for (let step = 0; step < searchBudget.angleSteps; step += 1) { + const angle = angleOffset + (step / searchBudget.angleSteps) * Math.PI * 2 + const x = anchor.x + Math.cos(angle) * orbit + const y = anchor.y + Math.sin(angle) * orbit + const overlapCandidate = { x, y, radius: worktree.radius } + if ( + spatialIndex + ? agentMapPackingCircleOverlaps(overlapCandidate, spatialIndex) + : placedWorktreesOverlap(overlapCandidate, placed) + ) { + continue + } + const distanceFromCenter = Math.hypot(x, y) + const candidate = { + x, + y, + enclosingRadius: Math.max(currentRadius, distanceFromCenter + worktree.radius), + distanceFromCenter + } + if (!best || comparePackingScores(candidate, best, scoreNeighbors) < 0) { + best = candidate + } + } + } + + if (best) { + worktree.x = best.x + worktree.y = best.y + return + } + let fallbackX = Number.NEGATIVE_INFINITY + for (const candidate of placed) { + fallbackX = Math.max(fallbackX, candidate.x + candidate.radius) + } + worktree.x = fallbackX + worktree.radius + AGENT_MAP_WORKTREE_GAP + worktree.y = 0 +} + +function enclosingRadius(worktrees: PackableWorktree[], x: number, y: number): number { + let radius = 0 + for (const worktree of worktrees) { + radius = Math.max(radius, Math.hypot(worktree.x - x, worktree.y - y) + worktree.radius) + } + return radius +} + +function packingSearchBudget(count: number): PackingSearchBudget { + if (count > VERY_LARGE_PACKING_THRESHOLD) { + return { angleSteps: 16, candidateAnchors: 12 } + } + if (count > LARGE_PACKING_THRESHOLD) { + return { angleSteps: 24, candidateAnchors: 64 } + } + return { + angleSteps: PACKING_ANGLE_STEPS, + candidateAnchors: MAX_PACKING_CANDIDATE_ANCHORS + } +} + +function findEnclosingCenter( + worktrees: PackableWorktree[], + bounds: { left: number; right: number; top: number; bottom: number } +): { x: number; y: number } { + let x = (bounds.left + bounds.right) / 2 + let y = (bounds.top + bounds.bottom) / 2 + let radius = enclosingRadius(worktrees, x, y) + let step = Math.max(bounds.right - bounds.left, bounds.bottom - bounds.top) / 4 + + while (step > SCORE_TOLERANCE) { + let improved = false + for (const [dx, dy] of CENTER_DIRECTIONS) { + const candidateX = x + dx * step + const candidateY = y + dy * step + const candidateRadius = enclosingRadius(worktrees, candidateX, candidateY) + if (candidateRadius < radius - SCORE_TOLERANCE) { + x = candidateX + y = candidateY + radius = candidateRadius + improved = true + } + } + if (!improved) { + step /= 2 + } + } + return { x, y } +} + +export function packAgentMapWorktrees(worktrees: T[]): T[] { + const packed = [...worktrees].sort((a, b) => b.radius - a.radius || compareStable(a.id, b.id)) + const placed: PackableWorktree[] = [] + const boundaryAnchors: PackableWorktree[] = [] + const searchBudget = packingSearchBudget(packed.length) + const spatialIndex: AgentMapPackingSpatialIndex | null = + packed.length > MAX_DIRECT_OVERLAP_WORKTREES ? new Map() : null + let currentRadius = 0 + for (const worktree of packed) { + if (placed.length > 0) { + placePackedWorktree( + worktree, + placed, + boundaryAnchors, + spatialIndex, + currentRadius, + searchBudget + ) + } + placed.push(worktree) + addBoundaryAnchor(boundaryAnchors, worktree, searchBudget.candidateAnchors) + if (spatialIndex) { + addAgentMapPackingCircle(spatialIndex, worktree) + } + currentRadius = Math.max(currentRadius, Math.hypot(worktree.x, worktree.y) + worktree.radius) + } + if (packed.length === 0) { + return packed + } + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const worktree of packed) { + left = Math.min(left, worktree.x - worktree.radius) + right = Math.max(right, worktree.x + worktree.radius) + top = Math.min(top, worktree.y - worktree.radius) + bottom = Math.max(bottom, worktree.y + worktree.radius) + } + const center = findEnclosingCenter(packed, { left, right, top, bottom }) + for (const worktree of packed) { + worktree.x -= center.x + worktree.y -= center.y + } + return packed.sort((a, b) => compareStable(a.id, b.id)) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css new file mode 100644 index 00000000000..1170931f1b8 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -0,0 +1,335 @@ +.agent-map-canvas { + background-image: radial-gradient( + color-mix(in srgb, var(--muted-foreground) 16%, transparent) 0.6px, + transparent 0.6px + ); + background-size: 20px 20px; +} + +.agent-map-canvas::before { + position: absolute; + inset: 0; + background: radial-gradient( + circle at 45% 48%, + color-mix(in srgb, var(--muted) 34%, transparent), + transparent 70% + ); + content: ''; + pointer-events: none; +} + +.agent-map-project-ring { + fill: color-mix(in srgb, var(--card) 22%, transparent); + stroke: color-mix(in srgb, var(--ring) 58%, transparent); + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-label { + fill: var(--foreground); + font-family: Geist, var(--font-sans); + font-weight: 600; + letter-spacing: 0.05em; + paint-order: stroke fill; + pointer-events: none; + stroke: var(--background); + stroke-linejoin: round; + stroke-width: 4px; +} + +.agent-map-project-label-frame { + overflow: visible; + pointer-events: none; +} + +.agent-map-project-label { + display: flex; + width: max-content; + max-width: calc(100% - 8px); + height: 100%; + align-items: center; + justify-content: center; + gap: 4px; + margin-inline: auto; + color: var(--foreground); + font-family: Geist, var(--font-sans); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.05em; + line-height: 1; + white-space: nowrap; +} + +.agent-map-project-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + -webkit-text-stroke: 4px var(--background); + paint-order: stroke fill; +} + +.agent-map-project-count, +.agent-map-worktree-count { + fill: var(--muted-foreground); + font-family: Geist, var(--font-sans); + letter-spacing: 0.05em; + paint-order: stroke fill; + pointer-events: none; + stroke: var(--background); + stroke-linejoin: round; + stroke-width: 4px; +} + +.agent-map-project-count { + font-size: 11px; + text-anchor: middle; +} + +.agent-map-worktree-ring { + cursor: pointer; + fill: color-mix(in srgb, var(--card) 48%, transparent); + outline: none; + stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-status-glow { + fill: none; + pointer-events: none; + stroke-width: 9; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-status-glow.fleet-status-blocked { + stroke: color-mix(in srgb, var(--color-red-500) 28%, transparent); +} + +.agent-map-worktree-status-glow.fleet-status-waiting { + stroke: color-mix(in srgb, var(--color-amber-500) 28%, transparent); +} + +.agent-map-worktree-status-glow.fleet-status-working { + stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent); +} + +.agent-map-worktree-ring:hover { + fill: color-mix(in srgb, var(--card) 72%, transparent); + stroke: var(--ring); +} + +.agent-map-worktree-ring:focus-visible { + fill: color-mix(in srgb, var(--ring) 8%, var(--card)); + outline: none; + stroke: var(--ring); + stroke-width: 2; +} + +.agent-map-worktree-ring.is-selected { + fill: color-mix(in srgb, var(--ring) 6%, var(--card)); + stroke: var(--ring); + stroke-width: 1.5; +} + +.agent-map-worktree-ring.is-open { + fill: color-mix(in srgb, var(--ring) 18%, var(--card)); + stroke: var(--ring); + stroke-width: 2.5; +} + +.agent-map-worktree-ring.is-working { + stroke: var(--color-yellow-500); +} + +.agent-map-worktree-ring.is-waiting { + stroke: var(--color-amber-500); +} + +.agent-map-worktree-ring.is-blocked { + stroke: var(--color-red-500); +} + +.agent-map-worktree-label { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + text-anchor: middle; +} + +.agent-map-worktree-count { + font-size: 11px; + text-anchor: middle; +} + +.agent-map-worktree-label-group { + opacity: 0; + pointer-events: none; +} + +.agent-map-worktree-label-group.is-visible, +.agent-map-worktree-label-group.is-active { + opacity: 1; +} + +.agent-map-worktree-count { + opacity: 0; +} + +.agent-map-worktree-label-group.is-count-visible .agent-map-worktree-count { + opacity: 1; +} + +.agent-map-agent-node { + cursor: pointer; + outline: none; +} + +.agent-map-worktree-lineage-link { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--muted-foreground) 34%, transparent); + stroke-linecap: round; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.agent-map-lineage-link { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent); + stroke-dasharray: 1 4; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-lineage-link.is-subagent { + stroke-dasharray: none; +} + +.agent-map-agent-hit { + fill: transparent; + stroke: transparent; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-mark { + fill: var(--background); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-working-glow { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--color-yellow-500) 32%, transparent); + stroke-width: 7; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-icon { + overflow: visible; + pointer-events: none; +} + +.agent-map-agent-icon > div { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + color: var(--foreground); +} + +.agent-map-agent-node:hover .agent-map-agent-hit, +.agent-map-agent-node:focus-visible .agent-map-agent-hit { + fill: color-mix(in srgb, var(--ring) 10%, transparent); + stroke: var(--ring); +} + +.agent-map-agent-node.is-selected .agent-map-agent-hit { + fill: color-mix(in srgb, var(--ring) 18%, transparent); + stroke: var(--ring); + stroke-width: 3; +} + +.agent-map-agent-node.is-selected .agent-map-agent-mark { + fill: color-mix(in srgb, var(--ring) 10%, var(--background)); +} + +.fleet-status-working .agent-map-agent-mark { + stroke: var(--color-yellow-500); +} + +.fleet-status-blocked .agent-map-agent-mark { + stroke: var(--color-red-500); +} + +.fleet-status-waiting .agent-map-agent-mark { + stroke: var(--color-amber-500); +} + +.fleet-status-done .agent-map-agent-mark { + stroke: var(--color-emerald-500); +} + +.fleet-status-idle .agent-map-agent-mark { + stroke: color-mix(in srgb, var(--color-neutral-500) 55%, transparent); +} + +.agent-map-agent-unread-mark { + fill: var(--color-amber-500); + pointer-events: none; + stroke: var(--background); + stroke-width: 2; +} + +.agent-map-aggregate-node circle { + fill: color-mix(in srgb, var(--muted-foreground) 12%, var(--card)); + stroke: color-mix(in srgb, var(--muted-foreground) 46%, transparent); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-aggregate-node text { + fill: var(--muted-foreground); + font-family: Geist, var(--font-sans); + font-size: 11px; + font-weight: 600; + text-anchor: middle; +} + +.agent-map-legend-dot { + display: block; + width: 7px; + height: 7px; + border-radius: 9999px; + background: var(--muted-foreground); + opacity: 0.42; +} + +.agent-map-legend-dot.fleet-status-working { + border: 2px solid var(--color-yellow-500); + border-top-color: transparent; + background: transparent; + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-blocked { + background: var(--color-red-500); + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-waiting { + background: var(--color-amber-500); + box-shadow: 0 0 4px color-mix(in srgb, var(--color-amber-500) 72%, transparent); + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-done { + background: var(--color-emerald-500); + opacity: 1; +} diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts new file mode 100644 index 00000000000..98f9ec369c5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch' + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'tab-1:leaf-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'old task', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Repo', + worktreeName: 'Worktree', + startedAt: 100, + finishedAt: null, + stateChangedAt: 100, + statusUpdatedAt: 150, + unseen: false, + ...overrides + } +} + +function event(overrides: Partial = {}): AgentStatusIpcPayload { + return { + paneKey: 'tab-1:leaf-1', + state: 'blocked', + prompt: 'Need a decision', + connectionId: null, + receivedAt: 300, + stateStartedAt: 250, + interactivePrompt: '{"question":"Continue?"}', + ...overrides + } +} + +function snapshot(cards: DashboardCard[] = [card()]): DashboardSnapshot { + return { generatedAt: 200, cards } +} + +describe('patchDashboardSnapshotFromAgentStatus', () => { + it('patches one known card without rebuilding the dashboard topology', () => { + const original = snapshot([card(), card({ paneKey: 'tab-2:leaf-2' })]) + const result = patchDashboardSnapshotFromAgentStatus(original, event()) + + expect(result.matched).toBe(true) + expect(result.snapshot.cards[0]).toMatchObject({ + bucket: 'attention', + dotState: 'blocked', + task: 'Need a decision', + lastUserMessage: 'Need a decision', + askSummary: '{"question":"Continue?"}', + stateChangedAt: 250, + statusUpdatedAt: 300, + unseen: true + }) + expect(result.snapshot.cards[1]).toBe(original.cards[1]) + }) + + it('preserves cached fields omitted from same-state hook pings', () => { + const original = snapshot([ + card({ + bucket: 'attention', + dotState: 'waiting', + askSummary: 'Pick one', + lastAgentMessage: 'Waiting', + stateChangedAt: 250, + unseen: true + }) + ]) + const result = patchDashboardSnapshotFromAgentStatus( + original, + event({ state: 'waiting', prompt: '', interactivePrompt: undefined, stateStartedAt: 250 }) + ) + + expect(result.snapshot.cards[0]).toMatchObject({ + task: 'old task', + askSummary: 'Pick one', + lastAgentMessage: 'Waiting', + unseen: true + }) + }) + + it('ignores stale, wrong-workspace, and session-only events', () => { + const original = snapshot() + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ receivedAt: 150 })).snapshot + ).toBe(original) + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ worktreeId: 'other' })).snapshot + ).toBe(original) + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ providerSessionOnly: true })).snapshot + ).toBe(original) + }) + + it('asks the caller for topology only when the pane is unknown', () => { + const original = snapshot() + const result = patchDashboardSnapshotFromAgentStatus( + original, + event({ paneKey: 'tab-new:leaf-new' }) + ) + + expect(result).toEqual({ matched: false, snapshot: original }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts new file mode 100644 index 00000000000..f3d32a9b462 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -0,0 +1,83 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardSubagent, + type DashboardSnapshot +} from '../../../../shared/dashboard-snapshot' +import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket' + +export type DashboardAgentStatusPatchResult = { + matched: boolean + snapshot: DashboardSnapshot +} + +function patchedSubagents( + card: DashboardCard, + event: AgentStatusIpcPayload +): DashboardCardSubagent[] | undefined { + if (event.subagents === undefined) { + return card.subagents + } + return event.subagents.map((subagent) => ({ + id: `${card.paneKey}\u0000subagent:${subagent.id}`, + name: subagent.description || subagent.agentType || 'unknown', + dotState: subagent.state + })) +} + +/** Applies an already bounded hook event inside the pop-out renderer. */ +export function patchDashboardSnapshotFromAgentStatus( + snapshot: DashboardSnapshot, + event: AgentStatusIpcPayload +): DashboardAgentStatusPatchResult { + if (event.providerSessionOnly) { + return { matched: true, snapshot } + } + const index = snapshot.cards.findIndex((card) => card.paneKey === event.paneKey) + if (index < 0) { + return { matched: false, snapshot } + } + const card = snapshot.cards[index] + if ( + event.receivedAt <= (card.statusUpdatedAt ?? 0) || + (event.worktreeId !== undefined && event.worktreeId !== card.worktreeId) + ) { + return { matched: true, snapshot } + } + + const stateChanged = event.stateStartedAt > card.stateChangedAt + const unseen = stateChanged ? card.startedAt !== 0 : card.unseen + const dotState = event.state + const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) + const nextCard: DashboardCard = { + ...card, + ...(event.agentType ? { agentType: event.agentType } : {}), + ...(event.prompt ? { task: event.prompt, lastUserMessage: event.prompt } : {}), + ...(event.lastAssistantMessage !== undefined + ? { lastAgentMessage: event.lastAssistantMessage || undefined } + : {}), + ...(event.orchestration?.parentPaneKey + ? { parentPaneKey: event.orchestration.parentPaneKey } + : {}), + bucket, + dotState, + unseen, + stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt, + statusUpdatedAt: event.receivedAt, + finishedAt: dotState === 'done' && stateChanged ? event.stateStartedAt : card.finishedAt, + askSummary: + bucket === 'attention' + ? event.interactivePrompt !== undefined + ? event.interactivePrompt || undefined + : card.askSummary + : undefined, + subagents: patchedSubagents(card, event) + } + const cards = snapshot.cards.slice() + cards[index] = nextCard + return { + matched: true, + snapshot: { ...snapshot, generatedAt: Math.max(snapshot.generatedAt, event.receivedAt), cards } + } +} diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts index 790334ace6e..e31e2279c9e 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts @@ -61,6 +61,7 @@ describe('buildPreviewTerminalOptions', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } }) @@ -77,6 +78,7 @@ describe('buildPreviewTerminalOptions', () => { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } }) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts index b76c7c14699..4846f2ee153 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts @@ -11,6 +11,7 @@ const LOCAL_MAC: DashboardCardTerminalInput = { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } @@ -73,6 +74,7 @@ describe('resolvePreviewShortcutAction', () => { hostPlatform: 'win32', localWindowsConpty: true, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } }) @@ -94,6 +96,7 @@ describe('resolvePreviewShortcutAction', () => { hostPlatform: 'win32', localWindowsConpty: false, windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } }) @@ -105,6 +108,44 @@ describe('resolvePreviewShortcutAction', () => { ).toEqual({ type: 'sendInput', data: '\x1b\r' }) }) + it('protects local ConPTY shells while preserving trusted Ctrl+Enter consumers', () => { + const conpty = contextFor({ + clientPlatform: 'win32', + terminalInput: { + hostPlatform: 'win32', + localWindowsConpty: true, + windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, + kittyKeyboardAdvertised: false + } + }) + expect( + resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), { + ...conpty, + kittyKeyboardActive: () => true + }) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + expect(resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), conpty)).toEqual({ + type: 'sendInput', + data: '\r' + }) + expect( + resolvePreviewShortcutAction( + keydown({ key: 'Enter', ctrlKey: true }), + contextFor({ + clientPlatform: 'win32', + terminalInput: { + hostPlatform: 'win32', + localWindowsConpty: true, + windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: true, + kittyKeyboardAdvertised: false + } + }) + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + }) + it('reports pane-scoped chords so the caller can swallow them', () => { expect( resolvePreviewShortcutAction(keydown({ key: 'd', code: 'KeyD', metaKey: true }), contextFor()) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts index e235c8d4992..1059c6b8b52 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts @@ -53,6 +53,7 @@ export function resolvePreviewShortcutAction( () => hostPlatform === 'win32', // Why: without it a terminal-first user's remapped tab.close chord — Ctrl+W // is a shell word-kill — reaches the shell in the pane but is swallowed here. - normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy) + normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy), + () => context.terminalInput?.ctrlEnterCsiU === true ) } diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts new file mode 100644 index 00000000000..c44843802f8 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts @@ -0,0 +1,35 @@ +import { useEffect, useState, type RefObject } from 'react' + +export type AgentMapCanvasSize = { width: number; height: number } + +export function useAgentMapCanvasSize( + containerRef: RefObject, + onResize: () => void +): AgentMapCanvasSize { + const [size, setSize] = useState({ width: 800, height: 560 }) + + useEffect(() => { + const container = containerRef.current + if (!container || typeof ResizeObserver === 'undefined') { + return + } + const measure = (): void => { + const next = container.getBoundingClientRect() + if (next.width <= 0 || next.height <= 0) { + return + } + onResize() + setSize((current) => + current.width === next.width && current.height === next.height + ? current + : { width: next.width, height: next.height } + ) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(container) + return () => observer.disconnect() + }, [containerRef, onResize]) + + return size +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx new file mode 100644 index 00000000000..a20c534e4d3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx @@ -0,0 +1,156 @@ +import { useCallback, useRef, useState } from 'react' +import type { + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout' +import { + AgentMapSnapshotWorkspaceMenu, + type AgentMapSnapshotWorkspaceMenuRequest +} from './AgentMapSnapshotWorkspaceMenu' +import { + AgentMapProjectContextMenuLoader, + type AgentMapProjectContextMenuRequest +} from './AgentMapProjectContextMenuLoader' +import { + AgentMapWorkspaceContextMenuLoader, + type AgentMapWorkspaceContextMenuRequest +} from './AgentMapWorkspaceContextMenuLoader' + +type UseAgentMapContextMenusArgs = { + /** True only where the app store lives; the pop-out gets the snapshot menu. */ + enabled: boolean + launchableAgentsByWorktreeId?: Record + onOpenChange?: (open: boolean) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +export function useAgentMapContextMenus({ + enabled, + launchableAgentsByWorktreeId, + onOpenChange, + onSpawnAgent, + onSleepWorkspace +}: UseAgentMapContextMenusArgs): { + contextMenus: React.JSX.Element | null + onOpenProjectContextMenu?: ( + event: React.MouseEvent, + project: AgentMapProjectRing + ) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void +} { + const requestIdRef = useRef(0) + const [workspaceRequest, setWorkspaceRequest] = + useState(null) + const [projectRequest, setProjectRequest] = useState( + null + ) + const [snapshotRequest, setSnapshotRequest] = + useState(null) + const snapshotMenuEnabled = + !enabled && (onSpawnAgent !== undefined || onSleepWorkspace !== undefined) + const openSnapshotWorkspaceMenu = useCallback( + (event: React.MouseEvent, worktree: AgentMapWorktreeRing): void => { + requestIdRef.current += 1 + setSnapshotRequest({ + id: requestIdRef.current, + worktreeId: worktree.worktreeId, + worktreeName: worktree.name, + launchableAgents: launchableAgentsByWorktreeId?.[worktree.worktreeId] ?? [], + clientX: event.clientX, + clientY: event.clientY + }) + }, + [launchableAgentsByWorktreeId] + ) + const openWorkspaceContextMenu = useCallback( + (event: React.MouseEvent, worktree: AgentMapWorktreeRing): void => { + requestIdRef.current += 1 + setProjectRequest(null) + setWorkspaceRequest({ + id: requestIdRef.current, + worktreeId: worktree.worktreeId, + executionHostId: worktree.executionHostId, + clientX: event.clientX, + clientY: event.clientY, + altKey: event.altKey + }) + }, + [] + ) + const openProjectContextMenu = useCallback( + (event: React.MouseEvent, project: AgentMapProjectRing): void => { + requestIdRef.current += 1 + setWorkspaceRequest(null) + setProjectRequest({ + id: requestIdRef.current, + projectId: project.id, + clientX: event.clientX, + clientY: event.clientY + }) + }, + [] + ) + const handleWorkspaceLifecycleComplete = useCallback((): void => { + setWorkspaceRequest(null) + }, []) + const handleProjectOpenChange = useCallback( + (open: boolean): void => { + onOpenChange?.(open) + if (!open) { + setProjectRequest(null) + } + }, + [onOpenChange] + ) + const handleSnapshotOpenChange = useCallback( + (open: boolean): void => { + onOpenChange?.(open) + if (!open) { + setSnapshotRequest(null) + } + }, + [onOpenChange] + ) + const contextMenus = snapshotMenuEnabled ? ( + snapshotRequest ? ( + + ) : null + ) : enabled ? ( + <> + {workspaceRequest ? ( + + ) : null} + {projectRequest ? ( + + ) : null} + + ) : null + + return { + contextMenus, + onOpenProjectContextMenu: enabled ? openProjectContextMenu : undefined, + onOpenWorkspaceContextMenu: enabled + ? openWorkspaceContextMenu + : snapshotMenuEnabled + ? openSnapshotWorkspaceMenu + : undefined + } +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts new file mode 100644 index 00000000000..2f22f63747e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react' +import type { AgentMapAgentNode } from './agent-map-layout' +import type { AgentMapViewport } from './agent-map-viewport-transition' + +type AgentMapSelectedFocusOptions = { + agents: AgentMapAgentNode[] + selectedPaneKey: string | null + viewportRef: { current: AgentMapViewport } + resolveFocusZoom: () => number + animateViewport: (from: AgentMapViewport, to: AgentMapViewport) => void + stopViewportTransition: () => void +} + +export function useAgentMapSelectedFocus({ + agents, + selectedPaneKey, + viewportRef, + resolveFocusZoom, + animateViewport, + stopViewportTransition +}: AgentMapSelectedFocusOptions): void { + const focusedAgentRef = useRef<{ + paneKey: string + x: number + y: number + zoom: number + } | null>(null) + useEffect(() => { + const selected = agents.find((agent) => agent.card.paneKey === selectedPaneKey) + if (!selectedPaneKey || !selected) { + focusedAgentRef.current = null + stopViewportTransition() + return + } + const targetZoom = resolveFocusZoom() + const focused = focusedAgentRef.current + if ( + focused?.paneKey === selectedPaneKey && + focused.x === selected.x && + focused.y === selected.y && + focused.zoom === targetZoom + ) { + return + } + focusedAgentRef.current = { + paneKey: selectedPaneKey, + x: selected.x, + y: selected.y, + zoom: targetZoom + } + animateViewport(viewportRef.current, { + center: { x: selected.x, y: selected.y }, + zoom: targetZoom + }) + }, [ + agents, + animateViewport, + resolveFocusZoom, + selectedPaneKey, + stopViewportTransition, + viewportRef + ]) +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts b/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts new file mode 100644 index 00000000000..b98bbf52afb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react' +import type { AgentMapState } from './agent-map-filter' + +const ALL_AGENT_STATES: AgentMapState[] = ['attention', 'working', 'done', 'idle'] + +/** Map-only state muting. It lives on the board rather than inside the map so + * the shared toolbar filter — the map has no rail of its own — can drive it. */ +export function useAgentMapStateFilter(): { + agentStates: ReadonlySet + toggleAgentState: (state: AgentMapState) => void + resetAgentStates: () => void +} { + const [agentStates, setAgentStates] = useState>( + () => new Set(ALL_AGENT_STATES) + ) + const toggleAgentState = useCallback((state: AgentMapState): void => { + setAgentStates((current) => { + const next = new Set(current) + if (!next.delete(state)) { + next.add(state) + } + return next + }) + }, []) + const resetAgentStates = useCallback((): void => { + setAgentStates(new Set(ALL_AGENT_STATES)) + }, []) + return { agentStates, toggleAgentState, resetAgentStates } +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts b/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts new file mode 100644 index 00000000000..91a8e6e1af1 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useRef } from 'react' +import { + startAgentMapViewportTransition, + type AgentMapViewport +} from './agent-map-viewport-transition' + +type AgentMapViewportTransitionOptions = { + durationMs: number + reducedMotion: boolean + onFrame: (viewport: AgentMapViewport) => void +} + +export function useAgentMapViewportTransition({ + durationMs, + reducedMotion, + onFrame +}: AgentMapViewportTransitionOptions): { + animate: (from: AgentMapViewport, to: AgentMapViewport) => void + stop: () => void +} { + const cancelRef = useRef<(() => void) | null>(null) + const stop = useCallback((): void => { + cancelRef.current?.() + cancelRef.current = null + }, []) + const animate = useCallback( + (from: AgentMapViewport, to: AgentMapViewport): void => { + stop() + if (reducedMotion) { + onFrame(to) + return + } + let cancel = (): void => {} + cancel = startAgentMapViewportTransition({ + from, + to, + durationMs, + onFrame, + onComplete: () => { + if (cancelRef.current === cancel) { + cancelRef.current = null + } + } + }) + cancelRef.current = cancel + }, + [durationMs, onFrame, reducedMotion, stop] + ) + useEffect(() => stop, [stop]) + return { animate, stop } +} diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx index 5d6853d848c..9a9c4e45a74 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -4,6 +4,10 @@ import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../../shared/agent-status-types' import { useDashboardSnapshot } from './useDashboardSnapshot' ;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -35,6 +39,9 @@ function snapshot(cards: DashboardCard[]): DashboardSnapshot { } let apply: (next: DashboardSnapshot) => void +let applyStatus: (next: AgentStatusIpcPayload) => void +let applyClear: (next: AgentStatusClearIpcPayload) => void +const requestSnapshot = vi.fn(async () => {}) const startViewTransition = vi.fn((cb: () => void) => { cb() return { @@ -62,7 +69,17 @@ describe('useDashboardSnapshot', () => { apply = cb return () => {} }, - requestSnapshot: vi.fn(async () => {}) + requestSnapshot + }, + agentStatus: { + onSet: (cb: (next: AgentStatusIpcPayload) => void) => { + applyStatus = cb + return () => {} + }, + onClear: (cb: (next: AgentStatusClearIpcPayload) => void) => { + applyClear = cb + return () => {} + } } } ;(document as unknown as { startViewTransition: unknown }).startViewTransition = @@ -140,4 +157,85 @@ describe('useDashboardSnapshot', () => { act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: {} })) expect(result.current.repoIconsByRepoId).toEqual({}) }) + + it('applies known hook status without requesting a rebuilt snapshot', () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ statusUpdatedAt: 100 })]))) + requestSnapshot.mockClear() + + act(() => + applyStatus({ + paneKey: 'pk', + state: 'blocked', + prompt: 'Choose', + connectionId: null, + receivedAt: 300, + stateStartedAt: 250 + }) + ) + + expect(requestSnapshot).not.toHaveBeenCalled() + expect(result.current.cards[0]).toMatchObject({ + bucket: 'attention', + task: 'Choose', + statusUpdatedAt: 300 + }) + }) + + it('trailing-debounces an unknown-pane burst into one topology refresh', () => { + vi.useFakeTimers() + renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({})]))) + requestSnapshot.mockClear() + + act(() => { + for (let index = 0; index < 10; index += 1) { + applyStatus({ + paneKey: `unknown-${index}`, + state: 'working', + prompt: '', + connectionId: null, + receivedAt: 300 + index, + stateStartedAt: 250 + index + }) + vi.advanceTimersByTime(40) + } + applyClear({ paneKey: 'pk' }) + vi.advanceTimersByTime(249) + }) + + expect(requestSnapshot).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(requestSnapshot).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) + + it('refreshes within the max wait while unknown events continue', () => { + vi.useFakeTimers() + renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({})]))) + requestSnapshot.mockClear() + + const unknown = (index: number): void => + applyStatus({ + paneKey: `unknown-${index}`, + state: 'working', + prompt: '', + connectionId: null, + receivedAt: 300 + index, + stateStartedAt: 250 + index + }) + act(() => { + unknown(0) + for (let index = 1; index <= 4; index += 1) { + vi.advanceTimersByTime(200) + unknown(index) + } + }) + expect(requestSnapshot).not.toHaveBeenCalled() + + act(() => vi.advanceTimersByTime(200)) + expect(requestSnapshot).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) }) diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts index 47704958da8..28e43d54e5b 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts @@ -1,9 +1,14 @@ import { useEffect, useRef, useState } from 'react' import { flushSync } from 'react-dom' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { EMPTY_DASHBOARD_SNAPSHOT, type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch' + +const TOPOLOGY_REFRESH_DEBOUNCE_MS = 250 +const TOPOLOGY_REFRESH_MAX_WAIT_MS = 1_000 /** Which column each card sits in — the only thing a view transition should * animate on. Content-only updates (such as new messages) must not. */ @@ -36,8 +41,58 @@ export function useDashboardSnapshot(): DashboardSnapshot { const [snapshot, setSnapshot] = useState(EMPTY_DASHBOARD_SNAPSHOT) const columnSignatureRef = useRef('') const retainedRepoIconsRef = useRef(undefined) + const snapshotRef = useRef(snapshot) useEffect(() => { + let topologyRefreshTimer: ReturnType | null = null + let topologyRefreshStartedAt: number | null = null + let staleRefreshTimer: ReturnType | null = null + const transientClearWatermarks = new Map() + + const requestTopologyRefresh = (): void => { + if (topologyRefreshTimer) { + clearTimeout(topologyRefreshTimer) + } + const now = Date.now() + topologyRefreshStartedAt ??= now + const remainingMaxWait = Math.max( + 0, + TOPOLOGY_REFRESH_MAX_WAIT_MS - (now - topologyRefreshStartedAt) + ) + topologyRefreshTimer = setTimeout( + () => { + topologyRefreshTimer = null + topologyRefreshStartedAt = null + void window.api.dashboard.requestSnapshot() + }, + Math.min(TOPOLOGY_REFRESH_DEBOUNCE_MS, remainingMaxWait) + ) + } + + const scheduleStaleRefresh = (next: DashboardSnapshot): void => { + if (staleRefreshTimer) { + clearTimeout(staleRefreshTimer) + staleRefreshTimer = null + } + let nextDeadline = Number.POSITIVE_INFINITY + for (const card of next.cards) { + if ( + card.statusUpdatedAt !== undefined && + (card.dotState === 'working' || + card.dotState === 'blocked' || + card.dotState === 'waiting') + ) { + nextDeadline = Math.min(nextDeadline, card.statusUpdatedAt + AGENT_STATUS_STALE_AFTER_MS) + } + } + if (Number.isFinite(nextDeadline)) { + staleRefreshTimer = setTimeout( + requestTopologyRefresh, + Math.max(0, nextDeadline - Date.now() + 1) + ) + } + } + const apply = (incoming: DashboardSnapshot): void => { // The bridge omits repoIconsByRepoId on throttled republishes when it has // not changed, rather than re-sending data URLs 4x/sec. Retain the last @@ -50,6 +105,8 @@ export function useDashboardSnapshot(): DashboardSnapshot { if (next.repoIconsByRepoId !== undefined) { retainedRepoIconsRef.current = next.repoIconsByRepoId } + snapshotRef.current = next + scheduleStaleRefresh(next) const nextSignature = columnSignature(next) const layoutChanged = nextSignature !== columnSignatureRef.current columnSignatureRef.current = nextSignature @@ -72,8 +129,45 @@ export function useDashboardSnapshot(): DashboardSnapshot { } const unsubscribe = window.api.dashboard.onSnapshot(apply) + const unsubscribeStatus = window.api.agentStatus.onSet((event) => { + if ( + typeof event.connectionId === 'string' && + event.receivedAt <= (transientClearWatermarks.get(event.connectionId) ?? -1) + ) { + return + } + const result = patchDashboardSnapshotFromAgentStatus(snapshotRef.current, event) + if (!result.matched) { + if (snapshotRef.current.generatedAt !== 0) { + requestTopologyRefresh() + } + return + } + if (result.snapshot !== snapshotRef.current) { + apply(result.snapshot) + } + }) + const unsubscribeClear = window.api.agentStatus.onClear((event) => { + if ('transient' in event && event.transient) { + transientClearWatermarks.set( + event.connectionId, + Math.max(transientClearWatermarks.get(event.connectionId) ?? -1, event.clearedAt) + ) + } + requestTopologyRefresh() + }) void window.api.dashboard.requestSnapshot() - return unsubscribe + return () => { + unsubscribe() + unsubscribeStatus() + unsubscribeClear() + if (topologyRefreshTimer) { + clearTimeout(topologyRefreshTimer) + } + if (staleRefreshTimer) { + clearTimeout(staleRefreshTimer) + } + } }, []) return snapshot diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx new file mode 100644 index 00000000000..ea1cdb80063 --- /dev/null +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' + +const mocks = vi.hoisted(() => ({ + useLiveDashboardSnapshot: vi.fn(() => ({ generatedAt: 1, cards: [] })), + blockingOverlay: false, + boardProps: null as Record | null, + activateTabAndFocusPane: vi.fn() +})) + +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: mocks.activateTabAndFocusPane +})) + +vi.mock('./useLiveDashboardSnapshot', () => ({ + useLiveDashboardSnapshot: mocks.useLiveDashboardSnapshot +})) + +vi.mock('../dashboard-popout/AgentKanbanBoard', () => ({ + AgentKanbanBoard: (props: Record) => { + mocks.boardProps = props + return mocks.blockingOverlay ?
: null + } +})) + +vi.mock('./AgentDashboardSettingsMenu', () => ({ + AgentDashboardSettingsMenu: () => null +})) + +vi.mock('../sidebar/use-workspace-kanban-outside-dismiss', () => ({ + isWorkspaceBoardKeepOpenTarget: () => false, + useWorkspaceKanbanOutsideDismiss: () => undefined +})) + +import { AgentDashboardDrawer } from './AgentDashboardDrawer' + +const initialState = useAppStore.getInitialState() + +beforeEach(() => { + useAppStore.setState( + { + agentDashboardDrawerOpen: false, + sidebarOpen: true, + sidebarWidth: 320 + }, + false + ) + mocks.useLiveDashboardSnapshot.mockClear() + mocks.blockingOverlay = false + mocks.boardProps = null + ;(window as unknown as { api: unknown }).api = { + dashboard: { openPopout: vi.fn().mockResolvedValue(undefined) } + } +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('AgentDashboardDrawer', () => { + it('derives no dashboard snapshot while closed', () => { + render() + + expect(mocks.useLiveDashboardSnapshot).not.toHaveBeenCalled() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + expect(mocks.useLiveDashboardSnapshot).toHaveBeenCalledTimes(1) + }) + + it('leaves Escape to an open terminal panel before dismissing the drawer', () => { + mocks.blockingOverlay = true + const view = render() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(true) + + mocks.blockingOverlay = false + view.rerender() + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) + }) + + it('hands map rendering to the dedicated popout', () => { + const openPopout = vi.mocked(window.api.dashboard.openPopout) + render() + expect(mocks.boardProps).toBeNull() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + expect(mocks.boardProps?.initialView).toBe('board') + expect(mocks.boardProps?.workspaceContextMenusEnabled).toBeUndefined() + const onOpenMap = mocks.boardProps?.onOpenMap + expect(onOpenMap).toBeTypeOf('function') + + act(() => { + ;(onOpenMap as () => void)() + }) + + expect(openPopout).toHaveBeenCalledWith('map') + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) + }) + + it('reveals a colliding worktree on the card execution host', () => { + const setActiveWorktree = vi.spyOn(useAppStore.getState(), 'setActiveWorktree') + render() + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + const onRevealAgent = mocks.boardProps?.onRevealAgent + expect(onRevealAgent).toBeTypeOf('function') + + act(() => { + ;( + onRevealAgent as (args: { + repoId: string + worktreeId: string + executionHostId?: string + tabId: string + leafId: string | null + }) => void + )({ + repoId: 'repo-1', + worktreeId: 'shared-worktree', + executionHostId: 'runtime:env-1', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + }) + + expect(setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith('tab-1', 'leaf-1', { + flashFocusedPane: true + }) + }) +}) diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx index 927c7930346..e9e0efcc13d 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx @@ -13,6 +13,7 @@ import { WORKSPACE_TOP_CHROME_HEIGHT } from '../sidebar/workspace-chrome-metrics' import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu' +import { launchDashboardAgent } from './launch-dashboard-agent' import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot' import { translate } from '@/i18n/i18n' @@ -47,7 +48,7 @@ function AgentDashboardDrawerBody({ }, []) const handleRevealAgent = useCallback( (args: AgentRevealArgs) => { - useAppStore.getState().setActiveWorktree(args.worktreeId) + useAppStore.getState().setActiveWorktree(args.worktreeId, args.executionHostId) activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) onClose() }, @@ -61,15 +62,23 @@ function AgentDashboardDrawerBody({ void window.api.dashboard.openPopout?.() }, [onClose]) + const handleOpenMap = useCallback(() => { + onClose() + void window.api.dashboard.openPopout?.('map') + }, [onClose]) + return ( = 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - function stateDotTooltipLabel(agent: DashboardAgentRowData, dotState: AgentDotState): string { if (agent.entry.interrupted === true) { return 'Interrupted by user' diff --git a/src/renderer/src/components/dashboard/DashboardPopoutBridge.tsx b/src/renderer/src/components/dashboard/DashboardPopoutBridge.tsx new file mode 100644 index 00000000000..1258f6e29a9 --- /dev/null +++ b/src/renderer/src/components/dashboard/DashboardPopoutBridge.tsx @@ -0,0 +1,7 @@ +import { useDashboardPopoutBridge } from './useDashboardPopoutBridge' + +/** Opt-in pop-out IPC and snapshot subscriptions. */ +export default function DashboardPopoutBridge(): null { + useDashboardPopoutBridge(true) + return null +} diff --git a/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts b/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts new file mode 100644 index 00000000000..9556910fbd1 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const rendererRoot = join(__dirname, '../..') + +function source(relativePath: string): string { + return readFileSync(join(rendererRoot, relativePath), 'utf8') +} + +describe('agent dashboard performance isolation', () => { + it('keeps all dashboard feature modules out of the disabled app and sidebar path', () => { + const app = source('App.tsx') + const sidebar = source('components/sidebar/index.tsx') + const nav = source('components/sidebar/SidebarNav.tsx') + + expect(app).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/) + expect(app).toContain("import('./components/dashboard/DashboardPopoutBridge')") + expect(sidebar).not.toMatch(/from ['"].*AgentDashboard(?:Drawer|SidebarHost)['"]/) + expect(sidebar).toContain("import('./AgentDashboardSidebarHost')") + expect(nav).not.toContain('useAgentBucketCounts') + expect(nav).not.toContain('shared/dashboard-snapshot') + expect(nav).toContain("import('./AgentDashboardSidebarEntry')") + }) + + it('keeps map computation out of the main-renderer drawer', () => { + const board = source('components/dashboard-popout/AgentKanbanBoard.tsx') + const drawer = source('components/dashboard/AgentDashboardDrawer.tsx') + + expect(board).toContain("import('./AgentDashboardMapView')") + expect(board).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/) + expect(drawer).toContain('initialView="board"') + expect(drawer).toContain("openPopout?.('map')") + expect(drawer).toContain('onOpenMap={handleOpenMap}') + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts new file mode 100644 index 00000000000..92df277ef9d --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { lastEnteredDoneAt } from './agent-finished-timestamp' + +function row(entry: AgentStatusEntry) { + return { rowSource: 'live' as const, state: entry.state, entry } +} + +function doneEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +describe('lastEnteredDoneAt session boundaries (STA-3386)', () => { + it('does not report an idle SessionStart as a completed turn', () => { + expect(lastEnteredDoneAt(row(doneEntry({ sessionBoundary: true })))).toBeNull() + }) + + it('falls through to a real completion displaced into history', () => { + expect( + lastEnteredDoneAt( + row( + doneEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'Real turn', startedAt: 1_000 }] + }) + ) + ) + ).toBe(1_000) + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 2c81cec0be2..6449e570fe1 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -15,7 +15,9 @@ export function lastEnteredDoneAt( return null } const entry = agent.entry - if (entry.state === 'done') { + // Why: a session-boundary done means the session connected idle (STA-3386) — nothing + // finished; fall through to history (which never records session boundaries). + if (entry.state === 'done' && entry.sessionBoundary !== true) { return entry.stateStartedAt } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { diff --git a/src/renderer/src/components/dashboard/agent-row-lineage.ts b/src/renderer/src/components/dashboard/agent-row-lineage.ts index 38efc312a67..9010c8e8ac1 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage.ts +++ b/src/renderer/src/components/dashboard/agent-row-lineage.ts @@ -3,6 +3,7 @@ import { buildAgentRowLineageTree } from './agent-row-lineage-model' export type AgentRowLineagePresentation = { depth: 0 | 1 + parentPaneKey?: string isFirstSibling: boolean isLastSibling: boolean childCount: number @@ -52,6 +53,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR // visible parent, but visual depth remains capped so dense sidebar // rows don't lose too much prompt width. depth: 1, + parentPaneKey: row.paneKey, isFirstSibling: index === 0, isLastSibling: index === children.length - 1, childCount: 0 @@ -69,3 +71,11 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR return ordered } + +export function dashboardCardParentPaneKey(row: DashboardAgentRowWithLineage): string | undefined { + const directParentPaneKey = row.entry.orchestration?.parentPaneKey + return ( + row.lineage.parentPaneKey ?? + (directParentPaneKey === row.paneKey ? undefined : directParentPaneKey) + ) +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts new file mode 100644 index 00000000000..f5db7045970 --- /dev/null +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { FolderWorkspace, ProjectGroup, TerminalTab } from '../../../../shared/types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' + +const NOW = 2_000_000_000 +const WORKSPACE_ID = folderWorkspaceKey('folder-1') +const TAB_ID = 'folder-tab' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +function folderWorkspace(): FolderWorkspace { + return { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Docs workspace', + folderPath: '/workspace/docs', + connectionId: 'ssh-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + createdAt: NOW, + updatedAt: NOW + } +} + +function projectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Documentation', + parentPath: '/workspace', + connectionId: 'ssh-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW + } +} + +function tab(): TerminalTab { + return { + id: TAB_ID, + ptyId: 'pty-folder', + worktreeId: WORKSPACE_ID, + title: 'codex', + customTitle: 'Docs reviewer', + color: null, + sortOrder: 0, + createdAt: NOW + } +} + +function entry(): AgentStatusEntry { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'Review the docs', + updatedAt: NOW, + stateStartedAt: NOW - 60_000, + stateHistory: [], + agentType: 'codex', + tabId: TAB_ID, + worktreeId: WORKSPACE_ID + } +} + +function state(): DashboardSnapshotState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [folderWorkspace()], + projectGroups: [projectGroup()], + tabsByWorktree: { [WORKSPACE_ID]: [tab()] }, + agentStatusByPaneKey: { [PANE_KEY]: entry() }, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-folder' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty-folder'] }, + runtimePaneTitlesByTabId: {}, + acknowledgedAgentsByPaneKey: {}, + settings: null + } as unknown as DashboardSnapshotState +} + +describe('buildDashboardSnapshot folder workspaces', () => { + it('places folder-workspace agents in their real project group without git assumptions', () => { + const snapshot = buildDashboardSnapshot(state(), NOW) + + expect(snapshot.cards).toHaveLength(1) + expect(snapshot.cards[0]).toMatchObject({ + paneKey: PANE_KEY, + repoId: 'folder-workspace:group-1', + repoName: 'Documentation', + worktreeId: WORKSPACE_ID, + worktreeName: 'Docs workspace', + workspaceKind: 'folder', + hostKind: 'ssh', + executionHostId: 'ssh:ssh-1' + }) + expect(snapshot.filterOptions?.projects).toEqual([ + { id: 'folder-workspace:group-1', label: 'Documentation' } + ]) + expect(snapshot.workspaces).toEqual([ + expect.objectContaining({ + repoId: 'folder-workspace:group-1', + worktreeId: WORKSPACE_ID, + repoName: 'Documentation', + worktreeName: 'Docs workspace', + workspaceKind: 'folder', + hostKind: 'ssh', + executionHostId: 'ssh:ssh-1' + }) + ]) + }) + + it('classifies a folder workspace from its own runtime host stamp', () => { + const runtimeState = state() + runtimeState.folderWorkspaces = [ + { ...folderWorkspace(), connectionId: null, executionHostId: 'runtime:environment-1' } + ] + runtimeState.projectGroups = [{ ...projectGroup(), connectionId: null }] + + const snapshot = buildDashboardSnapshot(runtimeState, NOW) + + expect(snapshot.cards[0].hostKind).toBe('remote') + expect(snapshot.cards[0].executionHostId).toBe('runtime:environment-1') + }) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index aa3534eac4d..90630a3d924 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' import { AGENT_STATUS_STALE_AFTER_MS, @@ -9,12 +9,47 @@ import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapsho import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab, Worktree } from '../../../../shared/types' import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/worktree-agent-orchestration-batch' +import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces' +import type * as AgentRowLineageModule from './agent-row-lineage' + +const mapMetadataCalls = vi.hoisted(() => ({ + hostKind: vi.fn(), + parentPaneKey: vi.fn() +})) + +vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dashboardCardMapWorkspaceMetadata: ( + ...args: Parameters + ) => { + mapMetadataCalls.hostKind() + return actual.dashboardCardMapWorkspaceMetadata(...args) + } + } +}) + +vi.mock('./agent-row-lineage', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dashboardCardParentPaneKey: (...args: Parameters) => { + mapMetadataCalls.parentPaneKey() + return actual.dashboardCardParentPaneKey(...args) + } + } +}) const NOW = 1_000_000_000 const TAB_ID = 'tab1' const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const CHILD_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const GRANDCHILD_LEAF_ID = '44444444-4444-4444-8444-444444444444' const GONE_LEAF_ID = '22222222-2222-4222-8222-222222222222' const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const CHILD_PANE_KEY = makePaneKey(TAB_ID, CHILD_LEAF_ID) +const GRANDCHILD_PANE_KEY = makePaneKey(TAB_ID, GRANDCHILD_LEAF_ID) function entry(overrides: Partial): AgentStatusEntry { return { @@ -110,6 +145,23 @@ describe('buildDashboardSnapshot', () => { ) expect(snapshot.cards).toEqual([]) + expect(snapshot.workspaces).toEqual([ + expect.objectContaining({ + repoId: 'r1', + worktreeId: 'w1', + repoName: 'Repo One', + worktreeName: 'wt-one', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree' + }), + expect.objectContaining({ + repoId: 'r2', + worktreeId: 'w2', + repoName: 'Repo Two', + worktreeName: 'wt-two' + }) + ]) expect(snapshot.filterOptions).toEqual({ projects: [ { id: 'r1', label: 'Repo One' }, @@ -147,6 +199,124 @@ describe('buildDashboardSnapshot', () => { expect(card.unseen).toBe(true) }) + it('publishes terminal-backed orchestrated workers under their direct parent', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ paneKey: PANE_KEY }), + [CHILD_PANE_KEY]: entry({ paneKey: CHILD_PANE_KEY }), + [GRANDCHILD_PANE_KEY]: entry({ paneKey: GRANDCHILD_PANE_KEY }) + }, + runtimeAgentOrchestrationByPaneKey: { + [CHILD_PANE_KEY]: { + taskId: 'worker-task', + dispatchId: 'worker-dispatch', + parentPaneKey: PANE_KEY + }, + [GRANDCHILD_PANE_KEY]: { + taskId: 'nested-worker-task', + dispatchId: 'nested-worker-dispatch', + parentPaneKey: CHILD_PANE_KEY + } + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_ID]: 'pty1', + [CHILD_LEAF_ID]: 'pty-child', + [GRANDCHILD_LEAF_ID]: 'pty-grandchild' + } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty-child', 'pty-grandchild'] } + }), + NOW + ) + + expect(snapshot.cards.map((card) => card.paneKey)).toEqual([ + PANE_KEY, + CHILD_PANE_KEY, + GRANDCHILD_PANE_KEY + ]) + expect(snapshot.cards[1]).toMatchObject({ + paneKey: CHILD_PANE_KEY, + parentPaneKey: PANE_KEY, + ptyId: 'pty-child' + }) + expect(snapshot.cards[2]).toMatchObject({ + paneKey: GRANDCHILD_PANE_KEY, + parentPaneKey: CHILD_PANE_KEY, + ptyId: 'pty-grandchild' + }) + }) + + it('preserves explicit lineage when a child runs in another worktree', () => { + const childTabId = 'child-tab' + const childPaneKey = makePaneKey(childTabId, CHILD_LEAF_ID) + const snapshot = buildDashboardSnapshot( + baseState({ + worktreesByRepo: { r1: [worktree(), worktree('w2', 'wt-two')] }, + tabsByWorktree: { w1: [tab()], w2: [tab(childTabId, 'w2')] }, + agentStatusByPaneKey: { + [PANE_KEY]: entry({ paneKey: PANE_KEY }), + [childPaneKey]: entry({ + paneKey: childPaneKey, + tabId: childTabId, + worktreeId: 'w2', + orchestration: { + taskId: 'child-task', + dispatchId: 'child-dispatch', + parentPaneKey: PANE_KEY + } + }) + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty1' } + }, + [childTabId]: { + root: { type: 'leaf', leafId: CHILD_LEAF_ID }, + activeLeafId: CHILD_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [CHILD_LEAF_ID]: 'pty-child' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1'], [childTabId]: ['pty-child'] } + }), + NOW + ) + + expect(snapshot.cards.find((item) => item.paneKey === childPaneKey)).toMatchObject({ + worktreeName: 'wt-two', + parentPaneKey: PANE_KEY + }) + }) + + it('does not publish malformed self-parent lineage', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ + orchestration: { + taskId: 'self-task', + dispatchId: 'self-dispatch', + parentPaneKey: PANE_KEY + } + }) + } + }), + NOW + ) + + expect(snapshot.cards[0].parentPaneKey).toBeUndefined() + }) + it('carries the tab conversation name and drops status-only titles', () => { const named = buildDashboardSnapshot( baseState({ @@ -256,16 +426,18 @@ describe('buildDashboardSnapshot', () => { expect(snapshot.cards[0].ptyId).toBeNull() }) - it('mutes unseen once the agent is acknowledged after its state change', () => { + it('moves an acknowledged completion to idle while retaining its raw done state', () => { const snapshot = buildDashboardSnapshot( baseState({ - agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, + agentStatusByPaneKey: { [PANE_KEY]: entry({ state: 'done' }) }, acknowledgedAgentsByPaneKey: { [PANE_KEY]: NOW - 1000 } }), NOW ) // ack (NOW-1000) is after stateStartedAt (NOW-5000) → seen. expect(snapshot.cards[0].unseen).toBe(false) + expect(snapshot.cards[0].dotState).toBe('done') + expect(snapshot.cards[0].bucket).toBe('idle') }) it('does not mark title-derived rows unseen from synthetic timestamps', () => { @@ -346,7 +518,13 @@ describe('buildDashboardSnapshot', () => { { id: 'reviewing', label: 'Reviewing', color: 'emerald' } ], worktreesByRepo: { - r1: [{ ...worktree(), workspaceStatus: 'reviewing' }] + r1: [ + { + ...worktree(), + workspaceStatus: 'reviewing', + parentWorktreeId: 'parent-worktree' + } as Worktree & { parentWorktreeId: string } + ] }, agentStatusByPaneKey: { [PANE_KEY]: entry({ @@ -369,12 +547,16 @@ describe('buildDashboardSnapshot', () => { workspaceStatusId: 'reviewing', workspaceStatusLabel: 'Reviewing', workspaceStatusColor: 'emerald', + parentWorktreeId: 'parent-worktree', subagents: [{ name: 'Review loop', dotState: 'working' }] }) }) it('skips card-only context for count snapshots', () => { + mapMetadataCalls.hostKind.mockClear() + mapMetadataCalls.parentPaneKey.mockClear() let linkedReviewReads = 0 + let hostIdentityReads = 0 const countWorktree = worktree() Object.defineProperty(countWorktree, 'linkedPR', { enumerable: true, @@ -383,8 +565,20 @@ describe('buildDashboardSnapshot', () => { return null } }) + const countRepo = { + id: 'r1', + path: '/r1', + displayName: 'Repo One', + badgeColor: '#000', + addedAt: 0, + get connectionId() { + hostIdentityReads += 1 + return null + } + } const snapshot = buildDashboardSnapshot( baseState({ + repos: [countRepo], worktreesByRepo: { r1: [countWorktree] }, agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }), @@ -394,11 +588,19 @@ describe('buildDashboardSnapshot', () => { expect(snapshot.cards[0].workspaceStatusId).toBeUndefined() expect(snapshot.cards[0].subagents).toBeUndefined() + expect(snapshot.cards[0].parentPaneKey).toBeUndefined() + expect(snapshot.cards[0].parentWorktreeId).toBeUndefined() + expect(snapshot.cards[0].hostKind).toBeUndefined() + expect(snapshot.cards[0].workspaceKind).toBeUndefined() // Why: the card has a live pty, so only the count-path gate keeps the // host-input resolution off the sidebar's per-status-tick rebuild. expect(snapshot.cards[0].ptyId).toBe('pty1') expect(snapshot.cards[0].terminalInput).toBeUndefined() + expect(snapshot.workspaces).toBeUndefined() expect(linkedReviewReads).toBe(0) + expect(hostIdentityReads).toBe(0) + expect(mapMetadataCalls.hostKind).not.toHaveBeenCalled() + expect(mapMetadataCalls.parentPaneKey).not.toHaveBeenCalled() }) it("resolves a live pty's host-input profile for card snapshots", () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index c7c3c9711cc..808ffba0236 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -1,25 +1,23 @@ import type { AppState } from '@/store/types' import { - DASHBOARD_MAX_LABEL_LENGTH, - type DashboardBucket, + DASHBOARD_MAX_MAP_WORKSPACES, + dashboardCardDisplayState, type DashboardCard, type DashboardCardDotState, type DashboardCardSubagent, - type DashboardSnapshot + type DashboardSnapshot, + type DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveDashboardCardTerminalInput, type DashboardCardTerminalInputState } from './dashboard-card-terminal-input' import { readDashboardClientHost } from './dashboard-client-host' -import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' -import { applyAgentRowLineage } from './agent-row-lineage' +import { applyAgentRowLineage, dashboardCardParentPaneKey } from './agent-row-lineage' import { lastEnteredDoneAt } from './agent-finished-timestamp' -import type { DashboardAgentRow } from './useDashboardData' import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows' import { selectLiveAgentStatusEntriesForWorktree, @@ -41,6 +39,23 @@ import { resolveDashboardCardContext, type DashboardCardContextState } from './dashboard-card-context' +import { + dashboardCardMapWorkspaceMetadata, + collectActiveDashboardWorkspaces +} from './dashboard-snapshot-workspaces' +import { + boundedLabel, + boundedLabelOrUndefined, + nonEmpty, + rowConversationName, + rowTask +} from './dashboard-card-labels' +import { + buildDashboardWorktreeLaunchOptions, + type DashboardLaunchDetectionState +} from './dashboard-worktree-launch-options' +import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options' +import { dashboardBucketForDotState } from './dashboard-card-bucket' /** The store slices the snapshot builder reads. Kept as a Pick so unit tests * can pass a partial store without constructing the whole AppState. */ @@ -60,68 +75,14 @@ export type DashboardSnapshotState = Pick< | 'settings' > & DashboardCardContextState & - Partial - -function bucketForState(state: DashboardAgentRow['state']): DashboardBucket { - switch (state) { - case 'working': - return 'working' - case 'done': - return 'done' - case 'idle': - return 'idle' - // blocked | waiting — the agent needs the user. - case 'blocked': - case 'waiting': - return 'attention' - } -} - -function rowTask(row: DashboardAgentRow): string { - return (row.entry.orchestration?.taskTitle ?? '').trim() || (row.entry.prompt ?? '').trim() -} - -function nonEmpty(value: string | undefined): string | undefined { - const trimmed = (value ?? '').trim() - return trimmed.length > 0 ? trimmed : undefined -} - -/** Why: these labels come from unbounded sources (`terminal rename`, OSC titles, - * display names). Over the validator's bound the card would be dropped. */ -function boundedLabel(value: string): string { - return value.length > DASHBOARD_MAX_LABEL_LENGTH - ? value.slice(0, DASHBOARD_MAX_LABEL_LENGTH) - : value -} - -function boundedLabelOrUndefined(value: string | undefined): string | undefined { - return value === undefined ? undefined : boundedLabel(value) -} - -/** Mirrors useAgentRowConversationName so the board and the sidebar label the - * same agent with the same name. */ -function rowConversationName( - row: DashboardAgentRow, - generatedTitlesEnabled: boolean -): string | undefined { - const parentPaneKey = row.entry.orchestration?.parentPaneKey - // Why: a child row rendered on its parent's tab does not own that tab's name. - if ( - row.lineage?.depth === 1 && - parentPaneKey !== undefined && - parsePaneKey(parentPaneKey)?.tabId === row.tab.id - ) { - return undefined - } - return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined -} + Partial /** * Derive the serializable dashboard snapshot from the live renderer store. * Reuses the exact per-worktree row machinery the sidebar uses * (buildWorktreeAgentRows + the indexed selectors), then flattens every - * worktree's rows into presentational cards. Subagent/child rows are excluded - * from the board (out of scope for v1). + * worktree's rows into presentational cards. Provider subagents without their + * own terminal stay folded into their spawning card. */ export function buildDashboardSnapshot( state: DashboardSnapshotState, @@ -129,40 +90,18 @@ export function buildDashboardSnapshot( options: { includeCardDetails?: boolean; includeFilterOptions?: boolean } = {} ): DashboardSnapshot { const cards: DashboardCard[] = [] + const workspaces: DashboardWorkspace[] | undefined = + options.includeCardDetails === false ? undefined : [] const clientHost = readDashboardClientHost() const repoIconsByRepoId: Record = {} const includeCardDetails = options.includeCardDetails !== false const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true - const activeWorktrees: { - repo: AppState['repos'][number] - worktree: AppState['worktreesByRepo'][string][number] - }[] = [] - - for (const repo of state.repos ?? []) { - for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { - if (!worktree.isArchived) { - activeWorktrees.push({ repo, worktree }) - } - } - } + const showIdle = state.settings?.experimentalAgentDashboardShowIdle === true + const activeWorktrees = collectActiveDashboardWorkspaces(state, includeCardDetails) const filterOptions = options.includeFilterOptions === false ? undefined - : { - // Why: filterOptions is snapshot-level, so an over-long project label - // costs the WHOLE board, not one card. Bound it at the producer. - projects: [...new Map(activeWorktrees.map(({ repo }) => [repo.id, repo])).values()].map( - (repo) => ({ id: repo.id, label: boundedLabel(repo.displayName) }) - ), - workspaceStatuses: (state.workspaceStatuses && state.workspaceStatuses.length > 0 - ? state.workspaceStatuses - : DEFAULT_WORKSPACE_STATUSES - ).map((status) => ({ - id: status.id, - label: status.label, - color: status.color - })) - } + : buildDashboardSnapshotFilterOptions(state, activeWorktrees) let singletonOrchestration: ReturnType | null = null let orchestrationByWorktree: ReturnType | null = null @@ -181,8 +120,10 @@ export function buildDashboardSnapshot( } } - for (const { repo, worktree } of activeWorktrees) { + for (const workspace of activeWorktrees) { + const { repo, worktree } = workspace const worktreeId = worktree.id + const parentWorktreeId = worktree.parentWorktreeId const liveEntries = selectLiveAgentStatusEntriesForWorktree(state, worktreeId) const migrationUnsupported = selectMigrationUnsupportedEntriesForWorktree(state, worktreeId) const entries = @@ -243,6 +184,21 @@ export function buildDashboardSnapshot( const context = includeCardDetails ? resolveDashboardCardContext(state, repo, worktree) : undefined + if (workspaces && workspaces.length < DASHBOARD_MAX_MAP_WORKSPACES) { + workspaces.push({ + repoId: workspace.projectId, + worktreeId, + repoName: boundedLabel(workspace.projectName), + worktreeName: boundedLabel(worktree.displayName), + ...(parentWorktreeId ? { parentWorktreeId } : {}), + ...dashboardCardMapWorkspaceMetadata(workspace, null, undefined, clientHost.platform), + workspaceStatusId: context?.workspaceStatus.id, + workspaceStatusLabel: context?.workspaceStatus.label, + workspaceStatusColor: context?.workspaceStatus.color, + hasReview: context?.hasReview, + review: context?.review + }) + } for (const row of rows) { // Child rows have no pane of their own; the board lists top-level agents. @@ -268,7 +224,10 @@ export function buildDashboardSnapshot( ? layoutPtyId : null const dotState = row.state as DashboardCardDotState - const bucket = bucketForState(row.state) + const unseen = + !isTitleDerived && + (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt + const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) // Why: only a live pty can open a preview terminal, and only a // card-rendering caller can open one — the sidebar's bucket counts must // not pay host resolution on every agent-status tick. @@ -286,8 +245,9 @@ export function buildDashboardSnapshot( osRelease: clientHost.osRelease }) : null + const finishedAt = lastEnteredDoneAt(row) // Only repos that actually contribute a card ship their icon. - repoIconsByRepoId[repo.id] = repo.repoIcon ?? null + repoIconsByRepoId[workspace.projectId] = workspace.repoIcon cards.push({ paneKey: row.paneKey, @@ -296,12 +256,24 @@ export function buildDashboardSnapshot( bucket, dotState, task: isTitleDerived ? '' : rowTask(row), - repoId: repo.id, + repoId: workspace.projectId, worktreeId, tabId, leafId, - repoName: boundedLabel(repo.displayName), + repoName: boundedLabel(workspace.projectName), worktreeName: boundedLabel(worktree.displayName), + ...(includeCardDetails + ? { + parentPaneKey: dashboardCardParentPaneKey(row), + ...(parentWorktreeId ? { parentWorktreeId } : {}), + ...dashboardCardMapWorkspaceMetadata( + workspace, + ptyId, + terminalInput ?? undefined, + clientHost.platform + ) + } + : {}), workspaceStatusId: context?.workspaceStatus.id, workspaceStatusLabel: context?.workspaceStatus.label, workspaceStatusColor: context?.workspaceStatus.color, @@ -311,13 +283,12 @@ export function buildDashboardSnapshot( lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt), lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage), startedAt: row.startedAt, - finishedAt: lastEnteredDoneAt(row), + finishedAt, stateChangedAt: row.entry.stateStartedAt || row.startedAt, + statusUpdatedAt: row.entry.updatedAt, // Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the // board and the sidebar bold/mute the same agents at the same time. - unseen: - !isTitleDerived && - (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt, + unseen, askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined, conversationName: boundedLabelOrUndefined(rowConversationName(row, generatedTitlesEnabled)), ...(terminalInput ? { terminalInput } : {}) @@ -328,8 +299,20 @@ export function buildDashboardSnapshot( return { generatedAt: now, cards, - showIdle: state.settings?.experimentalAgentDashboardShowIdle === true, + ...(workspaces ? { workspaces } : {}), + showIdle, filterOptions, + // Only the dashboard surfaces offer a launcher; the count-only rebuild that + // feeds the sidebar must not pay for host-detection lookups. + ...(includeCardDetails + ? { + launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions( + state, + cards, + workspaces + ) + } + : {}), repoIconsByRepoId } } diff --git a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts new file mode 100644 index 00000000000..a81129a227c --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts @@ -0,0 +1,15 @@ +import type { DashboardBucket, DashboardCardDotState } from '../../../../shared/dashboard-snapshot' + +export function dashboardBucketForDotState(state: DashboardCardDotState): DashboardBucket { + switch (state) { + case 'working': + return 'working' + case 'done': + return 'done' + case 'idle': + return 'idle' + case 'blocked': + case 'waiting': + return 'attention' + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-card-context.ts b/src/renderer/src/components/dashboard/dashboard-card-context.ts index 45b17b50d53..f376a52163c 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-context.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-context.ts @@ -37,10 +37,10 @@ function hasLinkedReview(worktree: Worktree): boolean { function resolveReview( state: DashboardCardContextState, - repo: Repo, + repo: Repo | null, worktree: Worktree ): DashboardCardReview | undefined { - if (!state.hostedReviewCache || !state.prCache || repo.kind === 'folder') { + if (!repo || !state.hostedReviewCache || !state.prCache || repo.kind === 'folder') { return undefined } const branch = branchName(worktree.branch) @@ -77,7 +77,7 @@ function resolveReview( export function resolveDashboardCardContext( state: DashboardCardContextState, - repo: Repo, + repo: Repo | null, worktree: Worktree ): DashboardCardContext { const statuses = diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts new file mode 100644 index 00000000000..df4f5f99d34 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -0,0 +1,43 @@ +import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' +import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapshot' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { DashboardAgentRow } from './useDashboardData' + +export function rowTask(row: DashboardAgentRow): string { + return (row.entry.orchestration?.taskTitle ?? '').trim() || (row.entry.prompt ?? '').trim() +} + +export function nonEmpty(value: string | undefined): string | undefined { + const trimmed = (value ?? '').trim() + return trimmed.length > 0 ? trimmed : undefined +} + +/** Why: these labels come from unbounded sources (`terminal rename`, OSC titles, + * display names). Over the validator's bound the card would be dropped. */ +export function boundedLabel(value: string): string { + return value.length > DASHBOARD_MAX_LABEL_LENGTH + ? value.slice(0, DASHBOARD_MAX_LABEL_LENGTH) + : value +} + +export function boundedLabelOrUndefined(value: string | undefined): string | undefined { + return value === undefined ? undefined : boundedLabel(value) +} + +/** Mirrors useAgentRowConversationName so the board and the sidebar label the + * same agent with the same name. */ +export function rowConversationName( + row: DashboardAgentRow, + generatedTitlesEnabled: boolean +): string | undefined { + const parentPaneKey = row.entry.orchestration?.parentPaneKey + // Why: a child row rendered on its parent's tab does not own that tab's name. + if ( + row.lineage?.depth === 1 && + parentPaneKey !== undefined && + parsePaneKey(parentPaneKey)?.tabId === row.tab.id + ) { + return undefined + } + return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined +} diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts index d7aad2ce5b4..77ac13c8b15 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts @@ -42,6 +42,7 @@ describe('resolveDashboardCardTerminalInput', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true }) }) @@ -62,6 +63,37 @@ describe('resolveDashboardCardTerminalInput', () => { expect(profile.kittyKeyboardAdvertised).toBe(true) }) + it('relays trusted Ctrl+Enter authority without coupling it to Shift+Enter', () => { + const droid = resolveDashboardCardTerminalInput( + stateWith({ + paneForegroundAgentByPaneKey: { + [WINDOWS_ARGS.paneKey]: { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + } + }), + WINDOWS_ARGS + ) + expect(droid.ctrlEnterCsiU).toBe(true) + + const pi = resolveDashboardCardTerminalInput( + stateWith({ + paneForegroundAgentByPaneKey: { + [WINDOWS_ARGS.paneKey]: { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + } + }), + WINDOWS_ARGS + ) + expect(pi.windowsShiftEnterEncoding).toBe('csi-u') + expect(pi.ctrlEnterCsiU).toBe(false) + }) + // Why: the pty runs Linux inside WSL, so byte protocols must follow it and // not the Windows client — the pane resolves this from its own session cwd. it('treats a WSL shell override as a non-ConPTY Linux-hosted pty', () => { @@ -125,6 +157,7 @@ describe('resolveDashboardCardTerminalInput', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true }) }) diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts index ab7fc611c75..b1b0f16c3ee 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts @@ -9,6 +9,7 @@ import { shouldDisableKittyKeyboardForTerminal } from '@/lib/pane-manager/termin import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility' import { resolveTerminalInputHostPlatform } from '@/components/terminal-pane/terminal-input-host-platform' import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-pane/terminal-windows-shift-enter' +import { hasCtrlEnterCsiUAuthorityForPane } from '@/components/terminal-pane/terminal-ctrl-enter' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' /** Store slices the pane's own input helpers read. */ @@ -122,6 +123,7 @@ export function resolveDashboardCardTerminalInput( localWindowsConpty: isLocalNativeWindowsConpty(windowsPtyContext), ...(args.osRelease === undefined ? {} : { osRelease: args.osRelease }), windowsShiftEnterEncoding: resolveWindowsShiftEnterEncodingForPane(state, args.paneKey), + ctrlEnterCsiU: hasCtrlEnterCsiUAuthorityForPane(state, args.paneKey), kittyKeyboardAdvertised: !shouldDisableKittyKeyboardForTerminal({ ...windowsPtyContext, // Why: launch identity is the only agent signal the board holds; it opts diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts new file mode 100644 index 00000000000..a17776433a3 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts @@ -0,0 +1,27 @@ +import type { AppState } from '@/store/types' +import type { DashboardFilterOptions } from '../../../../shared/dashboard-snapshot' +import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' +import { boundedLabel } from './dashboard-card-labels' +import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces' + +export function buildDashboardSnapshotFilterOptions( + state: Partial>, + activeWorkspaces: ActiveDashboardWorkspace[] +): DashboardFilterOptions { + const projects = [ + ...new Map(activeWorkspaces.map((workspace) => [workspace.projectId, workspace])).values() + ].map((workspace) => ({ + id: workspace.projectId, + label: boundedLabel(workspace.projectName) + })) + const workspaceStatuses = ( + state.workspaceStatuses && state.workspaceStatuses.length > 0 + ? state.workspaceStatuses + : DEFAULT_WORKSPACE_STATUSES + ).map((status) => ({ + id: status.id, + label: status.label, + color: status.color + })) + return { projects, workspaceStatuses } +} diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts new file mode 100644 index 00000000000..a83619511f8 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -0,0 +1,124 @@ +import type { AppState } from '@/store/types' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import type { + DashboardCard, + DashboardCardHostKind, + DashboardCardWorkspaceKind +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' + +export type ActiveDashboardWorkspace = { + projectId: string + projectName: string + repo: AppState['repos'][number] | null + repoIcon: RepoIcon | null + worktree: AppState['worktreesByRepo'][string][number] & { parentWorktreeId?: string | null } + workspaceKind: DashboardCardWorkspaceKind + remoteHostKind: Extract | null +} + +type DashboardWorkspaceState = Pick & + Partial> + +function remoteHostKind( + connectionId: string | null | undefined, + executionHostId: string | null | undefined +): ActiveDashboardWorkspace['remoteHostKind'] { + if (connectionId || executionHostId?.startsWith('ssh:')) { + return 'ssh' + } + return executionHostId && executionHostId !== 'local' ? 'remote' : null +} + +export function collectActiveDashboardWorkspaces( + state: DashboardWorkspaceState, + includeMapMetadata = true +): ActiveDashboardWorkspace[] { + const workspaces: ActiveDashboardWorkspace[] = [] + const seenWorkspaceIds = new Set() + + for (const repo of state.repos ?? []) { + for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { + if (worktree.isArchived) { + continue + } + seenWorkspaceIds.add(worktree.id) + workspaces.push({ + projectId: repo.id, + projectName: repo.displayName, + repo, + repoIcon: repo.repoIcon ?? null, + worktree, + workspaceKind: includeMapMetadata && isFolderRepo(repo) ? 'folder' : 'worktree', + remoteHostKind: includeMapMetadata + ? remoteHostKind(repo.connectionId, worktree.hostId ?? repo.executionHostId) + : null + }) + } + } + + const projectGroupsById = new Map( + (state.projectGroups ?? []).map((projectGroup) => [projectGroup.id, projectGroup]) + ) + for (const folderWorkspace of state.folderWorkspaces ?? []) { + const worktree = folderWorkspaceToWorktree(folderWorkspace) + if (folderWorkspace.isArchived || seenWorkspaceIds.has(worktree.id)) { + continue + } + const projectGroup = projectGroupsById.get(folderWorkspace.projectGroupId) + workspaces.push({ + projectId: `folder-workspace:${folderWorkspace.projectGroupId}`, + projectName: projectGroup?.name ?? folderWorkspace.name, + repo: null, + repoIcon: null, + worktree, + workspaceKind: 'folder', + remoteHostKind: includeMapMetadata + ? remoteHostKind( + folderWorkspace.connectionId ?? projectGroup?.connectionId, + worktree.hostId ?? projectGroup?.executionHostId + ) + : null + }) + } + return workspaces +} + +export function dashboardCardHostKind( + workspace: ActiveDashboardWorkspace, + ptyId: string | null, + terminalInput: DashboardCard['terminalInput'], + clientPlatform: NodeJS.Platform +): DashboardCardHostKind { + if (workspace.remoteHostKind) { + return workspace.remoteHostKind + } + if (ptyId && parseAppSshPtyId(ptyId)) { + return 'ssh' + } + if (ptyId && getRemoteRuntimePtyEnvironmentId(ptyId)) { + return 'remote' + } + return clientPlatform === 'win32' && terminalInput?.hostPlatform === 'linux' ? 'wsl' : 'local' +} + +export function dashboardCardMapWorkspaceMetadata( + workspace: ActiveDashboardWorkspace, + ptyId: string | null, + terminalInput: DashboardCard['terminalInput'], + clientPlatform: NodeJS.Platform +): { + hostKind: DashboardCardHostKind + executionHostId: ExecutionHostId + workspaceKind: DashboardCardWorkspaceKind +} { + return { + hostKind: dashboardCardHostKind(workspace, ptyId, terminalInput, clientPlatform), + executionHostId: getWorktreeExecutionHostId(workspace.worktree, workspace.repo ?? undefined), + workspaceKind: workspace.workspaceKind + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts new file mode 100644 index 00000000000..e7c0c04d85c --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { DASHBOARD_MAX_LAUNCH_WORKTREES } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { buildDashboardWorktreeLaunchOptions } from './dashboard-worktree-launch-options' + +type LaunchState = Parameters[0] + +function state(overrides: Partial = {}): LaunchState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + detectedAgentIds: [], + remoteDetectedAgentIds: {}, + runtimeDetectedAgentIds: {}, + settings: null, + ...overrides + } +} + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Ship it', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Dashboard', + startedAt: 1, + finishedAt: null, + stateChangedAt: 1, + unseen: false, + ...overrides + } +} + +describe('buildDashboardWorktreeLaunchOptions', () => { + it('stops at the bound the snapshot validator enforces', () => { + const cards = Array.from({ length: DASHBOARD_MAX_LAUNCH_WORKTREES + 25 }, (_unused, index) => + card({ paneKey: `pane-${index}`, worktreeId: `worktree-${index}` }) + ) + + const options = buildDashboardWorktreeLaunchOptions( + state({ detectedAgentIds: ['codex'] }), + cards + ) + + expect(Object.keys(options)).toHaveLength(DASHBOARD_MAX_LAUNCH_WORKTREES) + }) + + it('combines local detection with proven providers, honoring defaults and disabled agents', () => { + const options = buildDashboardWorktreeLaunchOptions( + state({ + detectedAgentIds: ['claude', 'codex'], + settings: { + defaultTuiAgent: 'codex', + disabledTuiAgents: ['claude'] + } as LaunchState['settings'] + }), + [card(), card({ paneKey: 'pane-2', agentType: 'gemini' })] + ) + + expect(options).toEqual({ 'worktree-1': ['codex', 'gemini'] }) + }) + + it('publishes detected launch choices for workspaces without cards', () => { + const workspace: DashboardWorkspace = { + repoId: 'repo-1', + worktreeId: 'empty-worktree', + repoName: 'Orca', + worktreeName: 'Empty', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree' + } + const options = buildDashboardWorktreeLaunchOptions( + state({ detectedAgentIds: ['claude', 'codex'] }), + [], + [workspace] + ) + + expect(options).toEqual({ 'empty-worktree': ['claude', 'codex'] }) + }) + + it('uses each git workspace execution host instead of local detection', () => { + const options = buildDashboardWorktreeLaunchOptions( + state({ + repos: [ + { id: 'repo-ssh', connectionId: 'ssh-1' }, + { id: 'repo-runtime', executionHostId: 'runtime:hub-1' } + ] as LaunchState['repos'], + worktreesByRepo: { + 'repo-ssh': [{ id: 'ssh-worktree', repoId: 'repo-ssh' }], + 'repo-runtime': [{ id: 'runtime-worktree', repoId: 'repo-runtime' }] + } as unknown as LaunchState['worktreesByRepo'], + detectedAgentIds: ['claude'], + remoteDetectedAgentIds: { 'ssh-1': ['grok'] }, + runtimeDetectedAgentIds: { 'hub-1': ['aider'] } + }), + [ + card({ repoId: 'repo-ssh', worktreeId: 'ssh-worktree', agentType: 'grok' }), + card({ repoId: 'repo-runtime', worktreeId: 'runtime-worktree', agentType: 'aider' }) + ] + ) + + expect(options).toEqual({ 'ssh-worktree': ['grok'], 'runtime-worktree': ['aider'] }) + }) + + it('resolves folder workspace detection through its project host', () => { + const worktreeId = folderWorkspaceKey('folder-1') + const options = buildDashboardWorktreeLaunchOptions( + state({ + folderWorkspaces: [ + { id: 'folder-1', projectGroupId: 'group-1', connectionId: 'ssh-folder' } + ] as LaunchState['folderWorkspaces'], + projectGroups: [{ id: 'group-1' }] as LaunchState['projectGroups'], + remoteDetectedAgentIds: { 'ssh-folder': ['goose'] } + }), + [card({ repoId: 'folder-workspace:group-1', worktreeId })] + ) + + expect(options).toEqual({ [worktreeId]: ['codex', 'goose'] }) + }) +}) diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts new file mode 100644 index 00000000000..16ca6c1f795 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -0,0 +1,127 @@ +import type { AppState } from '@/store/types' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + DASHBOARD_MAX_LAUNCH_WORKTREES, + type DashboardCard, + type DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { + filterEnabledTuiAgents, + TUI_AGENT_AUTO_PICK_ORDER +} from '../../../../shared/tui-agent-selection' +import type { TuiAgent } from '../../../../shared/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export type DashboardLaunchDetectionState = Pick< + AppState, + | 'detectedAgentIds' + | 'folderWorkspaces' + | 'projectGroups' + | 'remoteDetectedAgentIds' + | 'runtimeDetectedAgentIds' +> + +type DashboardLaunchOptionState = Pick & + Partial + +type DashboardLaunchCatalog = { + foldersById: Map + groupsById: Map + reposById: Map + worktreesByRepoAndId: Map> +} + +function buildDashboardLaunchCatalog(state: DashboardLaunchOptionState): DashboardLaunchCatalog { + return { + foldersById: new Map((state.folderWorkspaces ?? []).map((folder) => [folder.id, folder])), + groupsById: new Map((state.projectGroups ?? []).map((group) => [group.id, group])), + reposById: new Map((state.repos ?? []).map((repo) => [repo.id, repo])), + worktreesByRepoAndId: new Map( + Object.entries(state.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [ + repoId, + new Map(worktrees.map((worktree) => [worktree.id, worktree])) + ]) + ) + } +} + +function detectedAgentsForWorktree( + state: DashboardLaunchOptionState, + worktreeId: string, + repoId: string, + catalog: DashboardLaunchCatalog +): readonly TuiAgent[] { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folder = catalog.foldersById.get(workspaceScope.folderWorkspaceId) + const group = folder ? catalog.groupsById.get(folder.projectGroupId) : undefined + const host = parseExecutionHostId(group?.executionHostId) + if (host?.kind === 'runtime') { + return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + } + const connectionId = folder?.connectionId ?? group?.connectionId + return connectionId ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) : [] + } + + const worktree = catalog.worktreesByRepoAndId.get(repoId)?.get(worktreeId) + const repo = catalog.reposById.get(worktree?.repoId ?? repoId) + const host = parseExecutionHostId(worktree?.hostId ?? repo?.executionHostId) + if (host?.kind === 'runtime') { + return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + } + const connectionId = host?.kind === 'ssh' ? host.targetId : repo?.connectionId + return connectionId + ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) + : (state.detectedAgentIds ?? []) +} + +/** Host-detected choices plus providers already proven to run in the workspace. */ +export function buildDashboardWorktreeLaunchOptions( + state: DashboardLaunchOptionState, + cards: readonly DashboardCard[], + workspaces: readonly DashboardWorkspace[] = [] +): Record { + const catalog = buildDashboardLaunchCatalog(state) + const cardsByWorktreeId = new Map() + const repoIdByWorktreeId = new Map( + workspaces.map((workspace) => [workspace.worktreeId, workspace.repoId]) + ) + for (const card of cards) { + repoIdByWorktreeId.set(card.worktreeId, card.repoId) + const existing = cardsByWorktreeId.get(card.worktreeId) + if (existing) { + existing.push(card) + } else { + cardsByWorktreeId.set(card.worktreeId, [card]) + } + } + + const result: Record = {} + for (const [worktreeId, repoId] of repoIdByWorktreeId) { + // Past the validator's bound the snapshot itself would be rejected, so the + // launcher goes quiet for the tail rather than taking the board down. + if (Object.keys(result).length >= DASHBOARD_MAX_LAUNCH_WORKTREES) { + break + } + const worktreeCards = cardsByWorktreeId.get(worktreeId) ?? [] + const available = new Set( + detectedAgentsForWorktree(state, worktreeId, repoId, catalog) + ) + for (const card of worktreeCards) { + if (isTuiAgent(card.agentType)) { + available.add(card.agentType) + } + } + const enabled = filterEnabledTuiAgents( + TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => available.has(agent)), + state.settings?.disabledTuiAgents + ) + const preferred = state.settings?.defaultTuiAgent + result[worktreeId] = + preferred && preferred !== 'blank' && enabled.includes(preferred) + ? [preferred, ...enabled.filter((agent) => agent !== preferred)] + : enabled + } + return result +} diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts new file mode 100644 index 00000000000..a207244cdb9 --- /dev/null +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getKnownWorktreeById: vi.fn(), + setActiveWorktree: vi.fn(), + launchAgentInNewTab: vi.fn(), + getExecutionHostIdForWorktree: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: null, + getKnownWorktreeById: mocks.getKnownWorktreeById, + setActiveWorktree: mocks.setActiveWorktree + }) + } +})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: mocks.launchAgentInNewTab +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: mocks.getExecutionHostIdForWorktree +})) + +import { launchDashboardAgent } from './launch-dashboard-agent' + +describe('launchDashboardAgent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getExecutionHostIdForWorktree.mockReturnValue('ssh:docs') + mocks.getKnownWorktreeById.mockReturnValue({ id: 'folder:docs' }) + mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1' }) + }) + + it('activates a folder or git workspace on its execution host before launching', () => { + expect(launchDashboardAgent({ worktreeId: 'folder:docs', agent: 'codex' })).toBe(true) + expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.setActiveWorktree).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({ + agent: 'codex', + worktreeId: 'folder:docs', + launchSource: 'unknown' + }) + }) +}) diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts new file mode 100644 index 00000000000..be3b644e5a6 --- /dev/null +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts @@ -0,0 +1,23 @@ +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' + +/** Starts the requested agent through the same host-aware tab path as Quick Launch. */ +export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentArgs): boolean { + const state = useAppStore.getState() + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + const worktree = state.getKnownWorktreeById(worktreeId, executionHostId) + if (!worktree || !isTuiAgentEnabled(agent, state.settings?.disabledTuiAgents)) { + return false + } + state.setActiveWorktree(worktreeId, executionHostId) + return ( + launchAgentInNewTab({ + agent, + worktreeId, + launchSource: 'unknown' + }) !== null + ) +} diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx new file mode 100644 index 00000000000..02f9b5a5386 --- /dev/null +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + state: { + repos: [], + worktreesByRepo: {}, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + folderWorkspaces: [{ id: 'folder-1' }], + acknowledgedAgentsByPaneKey: {} as Record, + unrelatedEpoch: 0, + agentStatusEpoch: 0 + }, + buildDashboardSnapshot: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) +})) + +vi.mock('./build-dashboard-snapshot', () => ({ + buildDashboardSnapshot: mocks.buildDashboardSnapshot +})) + +import { useAgentBucketCounts } from './useAgentBucketCounts' + +afterEach(() => { + cleanup() + vi.clearAllMocks() + mocks.state.acknowledgedAgentsByPaneKey = {} + mocks.state.unrelatedEpoch = 0 +}) + +describe('useAgentBucketCounts', () => { + it('includes folder workspaces in the count snapshot inputs', () => { + mocks.buildDashboardSnapshot.mockImplementation((state: { folderWorkspaces?: unknown[] }) => ({ + generatedAt: 1, + cards: state.folderWorkspaces?.length ? [{ bucket: 'working' }] : [] + })) + + const { result } = renderHook(() => useAgentBucketCounts()) + + expect(result.current).toEqual({ attention: 0, working: 1, done: 0, idle: 0 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ folderWorkspaces: mocks.state.folderWorkspaces }), + expect.any(Number), + { includeCardDetails: false, includeFilterOptions: false } + ) + }) + + it('moves acknowledged completions to idle without recomputing for unrelated writes', () => { + mocks.buildDashboardSnapshot.mockImplementation( + (state: { acknowledgedAgentsByPaneKey?: Record }) => ({ + generatedAt: 1, + cards: [ + { + bucket: state.acknowledgedAgentsByPaneKey?.['pane-done'] ? 'idle' : 'done' + } + ] + }) + ) + const { result, rerender } = renderHook(() => useAgentBucketCounts()) + + expect(result.current).toEqual({ attention: 0, working: 0, done: 1, idle: 0 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + + mocks.state.unrelatedEpoch += 1 + rerender() + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + + mocks.state.acknowledgedAgentsByPaneKey = { 'pane-done': 1 } + rerender() + expect(result.current).toEqual({ attention: 0, working: 0, done: 0, idle: 1 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index 26b3fac9f3c..fe55b6e405c 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -25,6 +25,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, + folderWorkspaces, + acknowledgedAgentsByPaneKey, agentStatusEpoch } = useAppStore( useShallow((s) => ({ @@ -38,6 +40,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId: s.terminalLayoutsByTabId, ptyIdsByTabId: s.ptyIdsByTabId, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + folderWorkspaces: s.folderWorkspaces, + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, agentStatusEpoch: s.agentStatusEpoch })) ) @@ -55,8 +59,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, - // Counts do not render acknowledgement state, so avoid subscribing the sidebar to it. - acknowledgedAgentsByPaneKey: {}, + folderWorkspaces, + acknowledgedAgentsByPaneKey, // Same: counts never render a card's conversation name, so the // generated-title gate is moot and the sidebar stays off settings. settings: null @@ -86,6 +90,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, + folderWorkspaces, + acknowledgedAgentsByPaneKey, agentStatusEpoch ]) } diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 65c57ab2f4e..06926f919a0 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -23,6 +23,7 @@ export type DashboardAgentRow = { startedAt: number lineage?: { depth: 0 | 1 + parentPaneKey?: string isFirstSibling: boolean isLastSibling: boolean childCount: number diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index e4d853d6e28..c632f19bc4e 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -139,15 +139,37 @@ describe('useDashboardPopoutBridge', () => { expect(mocks.onPopoutOpenChanged).toHaveBeenCalledTimes(1) expect(mocks.subscribeStore).not.toHaveBeenCalled() + expect(mocks.buildDashboardSnapshot).not.toHaveBeenCalled() await act(async () => mocks.onPopoutOpenChanged.mock.calls[0][0](true)) expect(mocks.subscribeStore).toHaveBeenCalledTimes(1) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) const unsubscribe = mocks.subscribeStore.mock.results[0]?.value as () => void + const notifyStore = mocks.subscribeStore.mock.calls[0][0] await act(async () => mocks.onPopoutOpenChanged.mock.calls[0][0](false)) expect(unsubscribe).toHaveBeenCalledTimes(1) + const previousState = makeSnapshotWatchState() + await act(async () => notifyStore({ ...previousState, agentStatusEpoch: 1 }, previousState)) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + }) + + it('reveals the agent on its exact execution host', async () => { + await act(async () => root.render()) + + await act(async () => + mocks.onRevealAgent.mock.calls[0][0]({ + repoId: 'repo-1', + worktreeId: 'shared-worktree', + executionHostId: 'runtime:env-1', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + ) + + expect(mocks.setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') }) it('ignores unrelated store writes while retaining every snapshot input', () => { @@ -158,7 +180,6 @@ describe('useDashboardPopoutBridge', () => { 'repos', 'worktreesByRepo', 'tabsByWorktree', - 'agentStatusByPaneKey', 'retainedAgentsByPaneKey', 'migrationUnsupportedByPtyId', 'runtimeAgentOrchestrationByPaneKey', @@ -177,9 +198,15 @@ describe('useDashboardPopoutBridge', () => { key ).toBe(true) } + expect( + dashboardSnapshotInputsChanged( + { ...previousState, agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey } }, + previousState + ) + ).toBe(false) expect( dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState) - ).toBe(true) + ).toBe(false) // Why: each card's preview terminal keys against a host-input profile // derived from these. Not republishing leaves the pop-out encoding bytes @@ -291,7 +318,7 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { const previousState = makeSnapshotWatchState() act(() => mocks.subscribeStore.mock.calls[0][0]( - { ...previousState, agentStatusEpoch: 1 }, + { ...previousState, acknowledgedAgentsByPaneKey: { pane: 1 } }, previousState ) ) @@ -300,6 +327,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { const previousState = makeSnapshotWatchState() act(() => mocks.subscribeStore.mock.calls[0][0]({ ...previousState }, previousState)) } + const notifyStatusChurn = (): void => { + const previousState = makeSnapshotWatchState() + act(() => + mocks.subscribeStore.mock.calls[0][0]( + { + ...previousState, + agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey }, + agentStatusEpoch: previousState.agentStatusEpoch + 1 + }, + previousState + ) + ) + } const mountAndOpen = async (): Promise => { await act(async () => root.render()) setPopoutOpen(true) @@ -330,6 +370,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { expect(mocks.publishSnapshot).toHaveBeenCalledTimes(1) }) + it('does no snapshot work during live status churn', async () => { + await mountAndOpen() + mocks.buildDashboardSnapshot.mockClear() + mocks.publishSnapshot.mockClear() + + for (let index = 0; index < 100; index += 1) { + notifyStatusChurn() + } + + expect(mocks.buildDashboardSnapshot).not.toHaveBeenCalled() + expect(mocks.publishSnapshot).not.toHaveBeenCalled() + }) + // A burst collapses onto the trailing timer, so that edge carries most of the // republishes this PR exists to slim down. it('omits the icon map on the throttled trailing republish', async () => { diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 0c0d26909cc..2c42757036d 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -1,8 +1,10 @@ import { useEffect } from 'react' import { useAppStore, type AppState } from '@/store' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import type { RepoIcon } from '../../../../shared/repo-icon' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { launchDashboardAgent } from './launch-dashboard-agent' // Why: cap snapshot rebuilds during bursts of agent-status pings. The board is a // glanceable surface, so ~4 updates/sec is plenty and keeps the cross-worktree @@ -44,7 +46,6 @@ export function dashboardSnapshotInputsChanged( state.repos !== previousState.repos || state.worktreesByRepo !== previousState.worktreesByRepo || state.tabsByWorktree !== previousState.tabsByWorktree || - state.agentStatusByPaneKey !== previousState.agentStatusByPaneKey || state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey || state.migrationUnsupportedByPtyId !== previousState.migrationUnsupportedByPtyId || state.runtimeAgentOrchestrationByPaneKey !== previousState.runtimeAgentOrchestrationByPaneKey || @@ -57,8 +58,11 @@ export function dashboardSnapshotInputsChanged( // Why: settings controls idle visibility and generated conversation names. state.settings !== previousState.settings || state.workspaceStatuses !== previousState.workspaceStatuses || - // Why: freshness can change a bucket without replacing any backing map. - state.agentStatusEpoch !== previousState.agentStatusEpoch || + state.detectedAgentIds !== previousState.detectedAgentIds || + state.remoteDetectedAgentIds !== previousState.remoteDetectedAgentIds || + state.runtimeDetectedAgentIds !== previousState.runtimeDetectedAgentIds || + // Live hook status is relayed straight from main to the pop-out. Rebuilding + // every card here would put map refresh work on the main renderer's hot path. // Why: each card carries the host-input profile its preview terminal keys // against, and the pop-out cannot re-derive it. Every slice that resolves // an execution host must republish or the preview keeps encoding bytes for @@ -103,12 +107,30 @@ function watchSnapshotInputs(onChanged: () => void): () => void { * the agent's worktree and focus its pane in this (main) window. */ export function useDashboardPopoutBridge(enabled: boolean): void { + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onSpawnAgent?.(launchDashboardAgent) + }, [enabled]) + + // Sleeping from the popout runs the shared teardown here, where the store and + // the live terminal panes are — the popout only names the workspace. + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onSleepWorkspace?.(({ worktreeId }) => { + void runSleepWorktree(worktreeId) + }) + }, [enabled]) + useEffect(() => { if (!enabled) { return } return window.api.dashboard.onRevealAgent((args) => { - useAppStore.getState().setActiveWorktree(args.worktreeId) + useAppStore.getState().setActiveWorktree(args.worktreeId, args.executionHostId) activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) }) }, [enabled]) diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 43933bfae62..9782f91c697 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -51,6 +51,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds) const paneForegroundAgentByPaneKey = useAppStore((s) => s.paneForegroundAgentByPaneKey) + const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) + const remoteDetectedAgentIds = useAppStore((s) => s.remoteDetectedAgentIds) + const runtimeDetectedAgentIds = useAppStore((s) => s.runtimeDetectedAgentIds) // Why: freshness can flip a bucket without any backing map changing; the epoch // ticks on the freshness boundary so the memo re-derives stale-decayed cards. const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) @@ -87,6 +90,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, + detectedAgentIds, + remoteDetectedAgentIds, + runtimeDetectedAgentIds, // Why: read non-reactively — resolveWindowsShiftEnterEncoding takes // launch identity but never routes on it, so subscribing would only // rebuild the board. Matches the bridge's republish gate. @@ -122,6 +128,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, + detectedAgentIds, + remoteDetectedAgentIds, + runtimeDetectedAgentIds, agentStatusEpoch ] ) diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 2c53bd87285..f4bf62d3d54 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -13,6 +13,8 @@ import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll- import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' import { joinPath } from '@/lib/path' import { detectLanguage } from '@/lib/language-detect' +import { openFilePreviewToSide } from '@/lib/file-preview' +import { canOpenDiffSectionPreviewToSide } from './diff-section-preview' import { setWithLRU } from '@/lib/scroll-cache' import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' import { findWorktreeById } from '@/store/slices/worktree-helpers' @@ -1284,6 +1286,39 @@ export default function CombinedDiffViewer({ ] ) + // Why: match single-file HTML diffs — preview the on-disk working tree file + // beside the combined view when the section is still present on disk. + const openSectionPreview = useCallback( + (section: DiffSection) => { + if ( + !canOpenDiffSectionPreviewToSide({ + path: section.path, + status: section.status, + isCommitSurface: isCommitMode + }) + ) { + return + } + // Why: use this combined-diff tab's group, not worktree activeGroupId — + // in a multi-pane layout the active group may be a different split. + const state = useAppStore.getState() + const sourceGroupId = + (state.unifiedTabsByWorktree[file.worktreeId] ?? []).find( + (tab) => + tab.entityId === file.id && (tab.contentType === 'diff' || tab.contentType === 'editor') + )?.groupId ?? + activeGroupId ?? + null + openFilePreviewToSide({ + language: detectLanguage(section.path), + filePath: joinPath(file.filePath, section.path), + worktreeId: file.worktreeId, + sourceGroupId + }) + }, + [activeGroupId, file.filePath, file.id, file.worktreeId, isCommitMode] + ) + const handleSectionSave = useCallback( async (index: number) => { const section = sections[index] @@ -2024,6 +2059,15 @@ export default function CombinedDiffViewer({ openSectionTitle={ isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor' } + onOpenPreview={ + canOpenDiffSectionPreviewToSide({ + path: section.path, + status: section.status, + isCommitSurface: isCommitMode + }) + ? openSectionPreview + : undefined + } setSectionHeights={setSectionHeights} setSections={setSections} modifiedEditorsRef={modifiedEditorsRef} diff --git a/src/renderer/src/components/editor/DiffSectionHeader.tsx b/src/renderer/src/components/editor/DiffSectionHeader.tsx index 8df224e7c9d..9560ce6fcf3 100644 --- a/src/renderer/src/components/editor/DiffSectionHeader.tsx +++ b/src/renderer/src/components/editor/DiffSectionHeader.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' +import { ChevronDown, ChevronRight, ExternalLink, Eye } from 'lucide-react' import type { MouseEvent, ReactElement, ReactNode } from 'react' import { translate } from '@/i18n/i18n' @@ -11,6 +11,7 @@ export function DiffSectionHeader({ onToggle, onOpenSection, openSectionTitle, + onOpenPreview, trailingContent }: { path: string @@ -21,6 +22,7 @@ export function DiffSectionHeader({ onToggle: () => void onOpenSection: (event: MouseEvent) => void openSectionTitle: string + onOpenPreview?: (event: MouseEvent) => void trailingContent?: ReactNode }): ReactElement { return ( @@ -71,10 +73,39 @@ export function DiffSectionHeader({
{trailingContent} + {onOpenPreview != null && ( + + )} diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index 8b2c143f660..c6931fe5240 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -1,12 +1,4 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MutableRefObject, - type ReactNode -} from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { DiffOnMount } from '@monaco-editor/react' import type { editor as monacoEditor } from 'monaco-editor' import { monaco } from '@/lib/monaco-setup' @@ -14,26 +6,23 @@ import { detectLanguage } from '@/lib/language-detect' import { useAppStore } from '@/store' import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' -import { - useDiffCommentDecorator, - type DecoratedDiffComment -} from '../diff-comments/useDiffCommentDecorator' +import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' import { getDiffCommentPopoverLeft, getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' import { DiffSectionHeader } from './DiffSectionHeader' -import type { DiffSection } from './diff-section-types' import type { DiffComment } from '../../../../shared/types' import { isDiffComment } from '@/lib/diff-comment-compat' import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' import { DiffSectionBody } from './DiffSectionBody' import { useDiffSectionLayoutMetrics } from './useDiffSectionLayoutMetrics' -import { disposeUnattachedMonacoModelPaths } from './diff-monaco-model-disposal' import { getLiveDiffSectionRenderLimit } from './diff-section-live-render-limit' import { useDiffSectionFallbackCleanup } from './useDiffSectionFallbackCleanup' import { submitDiffSectionComment } from './diff-section-comment-submit' +import type { DiffSectionItemProps } from './diff-section-item-props' +import { useDiffSectionModelLifecycle } from './use-diff-section-model-lifecycle' export function DiffSectionItem({ section, @@ -49,6 +38,7 @@ export function DiffSectionItem({ toggleSection, openSection, openSectionTitle, + onOpenPreview, renderHeaderTrailingContent, onAddLineComment, addLineCommentLabel, @@ -59,42 +49,7 @@ export function DiffSectionItem({ setSections, modifiedEditorsRef, handleSectionSaveRef -}: { - section: DiffSection - index: number - isBranchMode: boolean - sideBySide: boolean - isDark: boolean - settings: { - terminalFontSize?: number - terminalFontFamily?: string - diffWordWrap?: boolean - } | null - sectionHeight: number | undefined - worktreeId?: string - loadSection: (index: number) => void - retrySection: (index: number) => void - toggleSection: (index: number) => void - openSection: (index: number) => void - openSectionTitle: string - renderHeaderTrailingContent?: (section: DiffSection, index: number) => ReactNode - onAddLineComment?: ( - section: DiffSection, - args: { - lineNumber: number - startLine?: number - body: string - } - ) => Promise - addLineCommentLabel?: string - addLineCommentPlaceholder?: string - inlineComments?: readonly DecoratedDiffComment[] - getCommentableLineNumbers?: (section: DiffSection) => readonly number[] | undefined - setSectionHeights: React.Dispatch>> - setSections: React.Dispatch> - modifiedEditorsRef: MutableRefObject> - handleSectionSaveRef: MutableRefObject<(index: number) => Promise> -}): React.JSX.Element { +}: DiffSectionItemProps): React.JSX.Element { const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const addDiffComment = useAppStore((s) => s.addDiffComment) const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) @@ -137,31 +92,10 @@ export function DiffSectionItem({ } | null>(null) const hasLineCommentAction = Boolean(worktreeId || onAddLineComment) - const disposeDiffModels = useCallback(() => { - window.setTimeout(() => { - disposeUnattachedMonacoModelPaths(monaco, [ - `${modelPathBase}:original`, - `${modelPathBase}:modified` - ]) - }, 0) - }, [modelPathBase]) - const disposeDiffModelsRef = useRef(disposeDiffModels) - disposeDiffModelsRef.current = disposeDiffModels - - const setSectionRootNode = useCallback((node: HTMLDivElement | null): void => { - if (node) { - return - } - // Why: virtualized diff rows remount as their keyed section/collapse state - // changes; the row root is the owner of the detached Monaco models. - disposeDiffModelsRef.current() - }, []) - - useEffect(() => { - if (section.collapsed) { - disposeDiffModels() - } - }, [disposeDiffModels, section.collapsed]) + const { disposeDiffModels, setSectionRootNode } = useDiffSectionModelLifecycle({ + modelPathBase, + collapsed: section.collapsed + }) // Why: only forward the pending scroll id when it matches a comment in this // section so unrelated sections don't keep re-rendering their decorator @@ -410,6 +344,13 @@ export function DiffSectionItem({ openSection(index) }} openSectionTitle={openSectionTitle} + onOpenPreview={ + onOpenPreview + ? () => { + onOpenPreview(section, index) + } + : undefined + } trailingContent={renderHeaderTrailingContent?.(section, index)} /> @@ -435,9 +376,7 @@ export function DiffSectionItem({ onCancelComment={() => setPopover(null)} onSubmitComment={handleSubmitComment} onRetrySection={retrySection} - onSaveLimitedDiff={() => { - void handleSectionSaveRef.current(index) - }} + onSaveLimitedDiff={() => void handleSectionSaveRef.current(index)} onMount={handleMount} /> )} diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 25b55305c06..adb0c3d1ed8 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -28,6 +28,7 @@ import { import { track } from '@/lib/telemetry' import { useNow } from '@/components/dashboard/useNow' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import { selectLivePtyIdsForWorktree } from '@/components/sidebar/worktree-card-status-inputs' import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -322,19 +323,6 @@ function formatAgentRelativeTime(agent: DashboardAgentRowData, now: number): str return startedAt > 0 ? `${formatTimeAgo(startedAt, now)}` : null } -function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { - const entry = agent.entry - if (entry.state === 'done') { - return entry.stateStartedAt - } - for (let i = entry.stateHistory.length - 1; i >= 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - function formatTimeAgo(ts: number, now: number): string { const delta = now - ts if (delta < 60_000) { diff --git a/src/renderer/src/components/editor/diff-section-item-props.ts b/src/renderer/src/components/editor/diff-section-item-props.ts new file mode 100644 index 00000000000..9cde9403dcf --- /dev/null +++ b/src/renderer/src/components/editor/diff-section-item-props.ts @@ -0,0 +1,38 @@ +import type { Dispatch, MutableRefObject, ReactNode, SetStateAction } from 'react' +import type { editor as monacoEditor } from 'monaco-editor' +import type { DecoratedDiffComment } from '../diff-comments/useDiffCommentDecorator' +import type { DiffSection } from './diff-section-types' + +export type DiffSectionItemProps = { + section: DiffSection + index: number + isBranchMode: boolean + sideBySide: boolean + isDark: boolean + settings: { + terminalFontSize?: number + terminalFontFamily?: string + diffWordWrap?: boolean + } | null + sectionHeight: number | undefined + worktreeId?: string + loadSection: (index: number) => void + retrySection: (index: number) => void + toggleSection: (index: number) => void + openSection: (index: number) => void + openSectionTitle: string + onOpenPreview?: (section: DiffSection, index: number) => void + renderHeaderTrailingContent?: (section: DiffSection, index: number) => ReactNode + onAddLineComment?: ( + section: DiffSection, + args: { lineNumber: number; startLine?: number; body: string } + ) => Promise + addLineCommentLabel?: string + addLineCommentPlaceholder?: string + inlineComments?: readonly DecoratedDiffComment[] + getCommentableLineNumbers?: (section: DiffSection) => readonly number[] | undefined + setSectionHeights: Dispatch>> + setSections: Dispatch> + modifiedEditorsRef: MutableRefObject> + handleSectionSaveRef: MutableRefObject<(index: number) => Promise> +} diff --git a/src/renderer/src/components/editor/diff-section-preview.test.ts b/src/renderer/src/components/editor/diff-section-preview.test.ts new file mode 100644 index 00000000000..20ecbae22a2 --- /dev/null +++ b/src/renderer/src/components/editor/diff-section-preview.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { canOpenDiffSectionPreviewToSide } from './diff-section-preview' + +describe('canOpenDiffSectionPreviewToSide', () => { + it('enables HTML working-tree sections that still exist on disk', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'docs/demo.html', + status: 'modified', + isCommitSurface: false + }) + ).toBe(true) + }) + + it('enables .htm paths', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'index.htm', + status: 'added', + isCommitSurface: false + }) + ).toBe(true) + }) + + it('enables untracked and renamed HTML files still present on disk', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'scratch.html', + status: 'untracked', + isCommitSurface: false + }) + ).toBe(true) + expect( + canOpenDiffSectionPreviewToSide({ + path: 'renamed.html', + status: 'renamed', + isCommitSurface: false + }) + ).toBe(true) + }) + + it('enables uppercase HTML extensions', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'Docs/DEMO.HTML', + status: 'modified', + isCommitSurface: false + }) + ).toBe(true) + }) + + it('disables deleted HTML files', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'gone.html', + status: 'deleted', + isCommitSurface: false + }) + ).toBe(false) + }) + + it('disables commit surfaces whose content may not match disk', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'docs/demo.html', + status: 'modified', + isCommitSurface: true + }) + ).toBe(false) + }) + + it('disables non-previewable languages', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'src/app.ts', + status: 'modified', + isCommitSurface: false + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/diff-section-preview.ts b/src/renderer/src/components/editor/diff-section-preview.ts new file mode 100644 index 00000000000..6e0fa684822 --- /dev/null +++ b/src/renderer/src/components/editor/diff-section-preview.ts @@ -0,0 +1,19 @@ +import { detectLanguage } from '@/lib/language-detect' +import { canPreviewLanguage } from '@/lib/file-preview' + +// Why: HTML preview renders the on-disk working-tree file, so combined-diff +// sections only get the affordance when that file still exists and the surface +// is not a historical commit snapshot whose content may not match disk. +export function canOpenDiffSectionPreviewToSide(params: { + path: string + status: string + isCommitSurface: boolean +}): boolean { + if (params.isCommitSurface) { + return false + } + if (params.status === 'deleted') { + return false + } + return canPreviewLanguage(detectLanguage(params.path)) +} diff --git a/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts b/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts new file mode 100644 index 00000000000..5d8c901c367 --- /dev/null +++ b/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useRef } from 'react' +import { monaco } from '@/lib/monaco-setup' +import { disposeUnattachedMonacoModelPaths } from './diff-monaco-model-disposal' + +// Why: virtualized section rows own Monaco model paths for their lifetime; +// dispose on unmount/collapse so remounts do not leak detached models. +export function useDiffSectionModelLifecycle(params: { + modelPathBase: string + collapsed: boolean +}): { + disposeDiffModels: () => void + setSectionRootNode: (node: HTMLDivElement | null) => void +} { + const disposeDiffModels = useCallback(() => { + window.setTimeout(() => { + disposeUnattachedMonacoModelPaths(monaco, [ + `${params.modelPathBase}:original`, + `${params.modelPathBase}:modified` + ]) + }, 0) + }, [params.modelPathBase]) + const disposeDiffModelsRef = useRef(disposeDiffModels) + // Keep callback-ref dispose path on the latest disposer without render-time mutation. + useEffect(() => { + disposeDiffModelsRef.current = disposeDiffModels + }, [disposeDiffModels]) + + const setSectionRootNode = useCallback((node: HTMLDivElement | null): void => { + if (node) { + return + } + disposeDiffModelsRef.current() + }, []) + + useEffect(() => { + if (params.collapsed) { + disposeDiffModels() + } + }, [disposeDiffModels, params.collapsed]) + + return { disposeDiffModels, setSectionRootNode } +} diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx new file mode 100644 index 00000000000..ea32cc99775 --- /dev/null +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom + +// The end-to-end shape of the 9 shipped lazy-chunk crash reports: a corrupt chunk +// fails, recovery requests a reload, the reload never lands, and the boundary files +// a react-error-boundary crash report instead of containing the failure. + +import { Suspense, act, type ReactElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { lazyWithRetry, resetLazyChunkReloadRequestsForTest } from '@/lib/lazy-with-retry' +import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary' + +const reportCrashMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +const RELOAD_SETTLE_GRACE_MS = 10_000 + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function BoundaryHarness({ children }: { children: ReactNode }): ReactElement { + return ( + + Loading...
}>{children} + + ) +} + +describe('RecoverableRenderErrorBoundary after a recovery reload never lands', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + reportCrashMock.mockReset() + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + vi.spyOn(window.location, 'reload').mockImplementation(() => undefined) + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + vi.restoreAllMocks() + vi.useRealTimers() + consoleError.mockRestore() + }) + + it('shows the fallback without filing a crash report', async () => { + const LazyCorruptChunk = lazyWithRetry( + () => Promise.reject(new SyntaxError("Unexpected token '}'")), + { retries: 0, reloadKey: 'right-sidebar' } + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render( + + + + ) + }) + + // Outlive the reload settle grace window, then let React commit the rejection. + await act(async () => { + await vi.advanceTimersByTimeAsync(RELOAD_SETTLE_GRACE_MS + 50) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + // Before the fix the boundary received the raw SyntaxError and filed a report; + // that is exactly what produced all 9 shipped crash reports. + expect(reportCrashMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx index eddfbd14252..e4c03e96aa0 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx @@ -50,6 +50,7 @@ export class RecoverableRenderErrorBoundary extends React.Component { + const row = { bottom: 120, left: 400 } + + it('places the tooltip under the cursor rather than the row', () => { + // Row-anchored placement would be align 0; the cursor is 64px into the row. + expect(cursorTooltipOffsets({ x: 464, y: 108 }, row)).toEqual({ align: 64, side: 6 }) + }) + + it('tracks the cursor across the row', () => { + const left = cursorTooltipOffsets({ x: 410, y: 108 }, row) + const right = cursorTooltipOffsets({ x: 610, y: 108 }, row) + + expect(right.align - left.align).toBe(200) + expect(right.side).toBe(left.side) + }) + + it('stays anchored to the cursor when the row moves under it', () => { + // The dropdown reflows while results stream in; re-measuring the row must + // keep the tooltip on the cursor, not drag it along with the row. + const before = cursorTooltipOffsets({ x: 464, y: 108 }, row) + const after = cursorTooltipOffsets({ x: 464, y: 108 }, { bottom: 148, left: 576 }) + + expect(row.left + before.align).toBe(576 + after.align) + expect(row.bottom + before.side).toBe(148 + after.side) + }) +}) + +describe('splitTrailingSegment', () => { + it('keeps the separator on the directory', () => { + expect(splitTrailingSegment('app/src/SecondaryNav.tsx')).toEqual({ + directory: 'app/src/', + filename: 'SecondaryNav.tsx' + }) + }) + + it('does not duplicate the root separator', () => { + expect(splitTrailingSegment('/foo')).toEqual({ directory: '/', filename: 'foo' }) + }) + + it('preserves Windows separators', () => { + expect(splitTrailingSegment('C:\\repo\\src\\a.ts')).toEqual({ + directory: 'C:\\repo\\src\\', + filename: 'a.ts' + }) + }) + + it('returns no directory for a bare filename', () => { + expect(splitTrailingSegment('README.md')).toEqual({ directory: '', filename: 'README.md' }) + }) +}) diff --git a/src/renderer/src/components/file-path-cursor-tooltip.tsx b/src/renderer/src/components/file-path-cursor-tooltip.tsx new file mode 100644 index 00000000000..d156d3a9343 --- /dev/null +++ b/src/renderer/src/components/file-path-cursor-tooltip.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { Slot } from 'radix-ui' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +// Clears the pointer without putting the label under the cursor's own hotspot. +const CURSOR_TOOLTIP_GAP = 18 + +/** + * Radix positions the tooltip against the trigger, so a cursor position has to + * be restated as offsets from the trigger's bottom-left corner to land under + * the pointer. + */ +export function cursorTooltipOffsets( + pointer: { x: number; y: number }, + trigger: { bottom: number; left: number } +): { align: number; side: number } { + return { + align: pointer.x - trigger.left, + side: pointer.y + CURSOR_TOOLTIP_GAP - trigger.bottom + } +} + +/** + * Splits a path for filename-first display, keeping the separator attached to + * the directory so `/foo` renders `foo` + `/` and Windows paths keep `\`. + */ +export function splitTrailingSegment(path: string): { directory: string; filename: string } { + const separatorIndex = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + + return separatorIndex === -1 + ? { directory: '', filename: path } + : { directory: path.slice(0, separatorIndex + 1), filename: path.slice(separatorIndex + 1) } +} + +/** + * Wraps a single result row so its full path appears in a system-styled tooltip + * anchored under the cursor. The child is the trigger; it receives a ref and a + * pointer-move handler. + */ +export function FilePathCursorTooltip({ + children, + path +}: { + children: React.ReactNode + path: string +}): React.JSX.Element { + const triggerRef = React.useRef(null) + const pointerRef = React.useRef<{ x: number; y: number } | null>(null) + const [open, setOpen] = React.useState(false) + const [offset, setOffset] = React.useState({ align: 0, side: 0 }) + + // Why: the offsets are only valid for the trigger's position at the moment + // Radix positions it. Results stream in and move rows while the open delay + // runs, so re-measure on open rather than trusting the pointer-move rect. + React.useLayoutEffect(() => { + const rect = triggerRef.current?.getBoundingClientRect() + const pointer = pointerRef.current + if (!open || !rect || !pointer) { + return + } + const next = cursorTooltipOffsets(pointer, rect) + setOffset((current) => + current.align === next.align && current.side === next.side ? current : next + ) + }, [open]) + + return ( + + + { + if (open) { + return + } + pointerRef.current = { x: event.clientX, y: event.clientY } + }} + > + {children} + + + {/* Anchored under the cursor the way a system tooltip is, rather than to + the row. Collision shifting still applies near the viewport edge. */} + + {path} + + + ) +} diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index 4aa11354059..a773910d332 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -242,4 +242,23 @@ describe('GitHubItemDialog source host boundaries', () => { expect(checksTab).toContain('item={displayWorkItem ?? workItem}') }) + + it('records state authority for dialog state mutations so stale list refetches cannot revert them (STA-3343)', () => { + const source = componentSource('GitHubItemDialog.tsx') + + // Issue close/reopen: assert on optimistic apply, revert on failure. + const editSection = sourceBetween(source, 'function GHEditSection', 'const closeAsDuplicate') + expect(editSection).toContain('assertTaskPageGitHubDialogStateAuthority({') + expect(editSection).toContain('if (authority?.revert())') + + // PR close/reopen + merge: same protection for the shared Tasks list rows. + const actionsSection = sourceBetween( + source, + 'function PRActionsPanel', + 'function CommentReplyForm' + ) + expect(actionsSection.match(/assertTaskPageGitHubDialogStateAuthority\(\{/g)).toHaveLength(2) + expect(actionsSection).toContain('if (authority.revert())') + expect(actionsSection).toContain("state: 'merged'") + }) }) diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 39f43539ee9..59d02e24f31 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -141,7 +141,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS const patchProjectRowIssueType = useAppStore((s) => s.patchProjectRowIssueType) const addRepoFromStore = useAppStore((s) => s.addRepo) const repos = useAppStore((s) => s.repos) - const { lookupSlug, ready: slugIndexReady } = useRepoSlugIndex() + const { lookupSlug, lookupSlugMatches, ready: slugIndexReady } = useRepoSlugIndex() const mountedRef = useMountedRef() const activeProject = settings?.githubProjects?.activeProject ?? null @@ -375,9 +375,14 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS const filteredTable = useMemo( () => table && slugIndexReady - ? filterProjectTableRowsBySelectedRepos(table, lookupSlug, slugIndexReady, selectedRepoIds) + ? filterProjectTableRowsBySelectedRepos( + table, + lookupSlugMatches, + slugIndexReady, + selectedRepoIds + ) : null, - [table, slugIndexReady, lookupSlug, selectedRepoIds] + [table, slugIndexReady, lookupSlugMatches, selectedRepoIds] ) const lastFilteredTableRef = useRef(null) // Why: ref-cache prevents a blank table while the slug index rebuilds, without forcing a second render. @@ -528,7 +533,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS } const resolution = resolveSelectedProjectRowRepo({ row, - lookupSlug, + lookupSlugMatches, host: table.project.host, slugIndexReady, selectedRepoIds @@ -584,7 +589,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS currentCacheKey, table, buildOrigin, - lookupSlug, + lookupSlugMatches, slugIndexReady, selectedRepoIds, openProjectRowUrlWithToast @@ -602,7 +607,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS } const resolution = resolveSelectedProjectRowRepo({ row, - lookupSlug, + lookupSlugMatches, host: table.project.host, slugIndexReady, selectedRepoIds @@ -671,7 +676,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS currentCacheKey, table, buildOrigin, - lookupSlug, + lookupSlugMatches, slugIndexReady, selectedRepoIds, openProjectRowUrlWithToast diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index 708cef2fac5..8871c047f7a 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -17,6 +17,11 @@ function repo(id: string): Repo { } } +/** The common case: every match owns the slug through its own origin remote. */ +function originOnly(matches: Repo[]): { origin: Repo[]; upstream: Repo[] } { + return { origin: matches, upstream: [] } +} + function row(id: string, repository: string | null): GitHubProjectRow { return { id, @@ -102,7 +107,8 @@ describe('filterProjectTableRowsBySelectedRepos', () => { const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')] const filtered = filterProjectTableRowsBySelectedRepos( table(rows), - (slug) => (slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]), + (slug) => + originOnly(slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]), true, new Set(['repo-1']) ) @@ -115,7 +121,7 @@ describe('filterProjectTableRowsBySelectedRepos', () => { const rows = [row('hidden', 'acme/orca')] const filtered = filterProjectTableRowsBySelectedRepos( table(rows), - () => [repo('repo-2')], + () => originOnly([repo('repo-2')]), true, new Set(['repo-1']) ) @@ -124,11 +130,24 @@ describe('filterProjectTableRowsBySelectedRepos', () => { expect(filtered.totalCount).toBe(0) }) + it('keeps a row whose only selected match is a fork of the row s repo', () => { + const rows = [row('fork-only', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => ({ origin: [], upstream: [repo('fork')] }), + true, + new Set(['fork']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['fork-only']) + expect(filtered.totalCount).toBe(1) + }) + it('keeps a row with multiple selected matches for action ambiguity handling', () => { const rows = [row('ambiguous', 'acme/orca')] const filtered = filterProjectTableRowsBySelectedRepos( table(rows), - () => [repo('repo-1'), repo('repo-2'), repo('repo-3')], + () => originOnly([repo('repo-1'), repo('repo-2'), repo('repo-3')]), true, new Set(['repo-1', 'repo-2']) ) @@ -141,7 +160,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('reports loading without reading stale slug matches', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('loading', 'acme/orca'), - lookupSlug: () => { + lookupSlugMatches: () => { throw new Error('should not read stale matches') }, slugIndexReady: false, @@ -154,7 +173,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('reports invalid slug for rows without a repository', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('missing-slug', null), - lookupSlug: () => [repo('repo-1')], + lookupSlugMatches: () => originOnly([repo('repo-1')]), slugIndexReady: true, selectedRepoIds: new Set(['repo-1']) }) @@ -165,7 +184,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('reports no global match when Orca has no repo for the slug', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('missing', 'acme/orca'), - lookupSlug: () => [], + lookupSlugMatches: () => originOnly([]), slugIndexReady: true, selectedRepoIds: new Set(['repo-1']) }) @@ -176,7 +195,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('reports global-only matches when the repo is not selected', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('unselected', 'acme/orca'), - lookupSlug: () => [repo('repo-2')], + lookupSlugMatches: () => originOnly([repo('repo-2')]), slugIndexReady: true, selectedRepoIds: new Set(['repo-1']) }) @@ -187,7 +206,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('returns the selected match when exactly one matching repo is selected', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('selected', 'acme/orca'), - lookupSlug: () => [repo('repo-1'), repo('repo-2')], + lookupSlugMatches: () => originOnly([repo('repo-1'), repo('repo-2')]), slugIndexReady: true, selectedRepoIds: new Set(['repo-2']) }) @@ -196,28 +215,64 @@ describe('resolveSelectedProjectRowRepo', () => { }) it('passes the project host into repository matching', () => { - const lookupSlug = vi.fn(() => [repo('repo-1')]) + const lookupSlugMatches = vi.fn(() => originOnly([repo('repo-1')])) expect( resolveSelectedProjectRowRepo({ row: row('enterprise', 'acme/orca'), - lookupSlug, + lookupSlugMatches, host: 'ghe.example:8443', slugIndexReady: true, selectedRepoIds: new Set(['repo-1']) }) ).toMatchObject({ status: 'selected_match' }) - expect(lookupSlug).toHaveBeenCalledWith('acme/orca', 'ghe.example:8443') + expect(lookupSlugMatches).toHaveBeenCalledWith('acme/orca', 'ghe.example:8443') }) it('reports ambiguity when multiple matching repos are selected', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('ambiguous', 'acme/orca'), - lookupSlug: () => [repo('repo-1'), repo('repo-2')], + lookupSlugMatches: () => originOnly([repo('repo-1'), repo('repo-2')]), slugIndexReady: true, selectedRepoIds: new Set(['repo-1', 'repo-2']) }) expect(resolution.status).toBe('ambiguous_selected_match') }) + + it('falls through to a selected fork when the upstream clone is unselected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('fork-selected', 'acme/orca'), + lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }), + slugIndexReady: true, + selectedRepoIds: new Set(['fork']) + }) + + expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'fork' } }) + }) + + it('prefers the selected upstream clone over a selected fork of it', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('both-selected', 'acme/orca'), + lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }), + slugIndexReady: true, + selectedRepoIds: new Set(['upstream', 'fork']) + }) + + expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'upstream' } }) + }) + + it('still reports no selection when neither the upstream clone nor the fork is selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('neither', 'acme/orca'), + lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }), + slugIndexReady: true, + selectedRepoIds: new Set(['other']) + }) + + expect(resolution).toMatchObject({ + status: 'unselected_match', + globalMatches: [{ id: 'upstream' }, { id: 'fork' }] + }) + }) }) diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index 1aa2f4c3ed8..52dd0138274 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -6,6 +6,13 @@ export type ProjectRowSlugLookup = ( host?: string ) => readonly Repo[] +/** Origin matches own the slug through their own remote; upstream matches are + * forks whose parent the row names. */ +export type ProjectRowSlugMatchLookup = ( + slug: string | null | undefined, + host?: string +) => { origin: readonly Repo[]; upstream: readonly Repo[] } + export type SelectedProjectRowResolution = | { status: 'loading' } | { status: 'invalid_slug' } @@ -20,7 +27,7 @@ export type SelectedProjectRowResolution = export function resolveSelectedProjectRowRepo(input: { row: GitHubProjectRow - lookupSlug: ProjectRowSlugLookup + lookupSlugMatches: ProjectRowSlugMatchLookup host?: string slugIndexReady: boolean selectedRepoIds: ReadonlySet @@ -38,12 +45,20 @@ export function resolveSelectedProjectRowRepo(input: { return { status: 'invalid_slug' } } - const globalMatches = input.lookupSlug(repository, input.host) + const { origin, upstream } = input.lookupSlugMatches(repository, input.host) + const globalMatches = [...origin, ...upstream] if (globalMatches.length === 0) { return { status: 'no_global_match' } } - const selectedMatches = globalMatches.filter((match) => input.selectedRepoIds.has(match.id)) + // Why: prefer origin only among repos the user actually selected. Applying + // that preference globally let an open-but-unselected clone of the upstream + // repo hide the selected fork, reproducing #12647 for anyone holding both. + const selectedOrigin = origin.filter((match) => input.selectedRepoIds.has(match.id)) + const selectedMatches = + selectedOrigin.length > 0 + ? selectedOrigin + : upstream.filter((match) => input.selectedRepoIds.has(match.id)) if (selectedMatches.length === 0) { return { status: 'unselected_match', globalMatches } } @@ -76,14 +91,14 @@ export function filterProjectTableRowsByOpenRepos( export function filterProjectTableRowsBySelectedRepos( table: GitHubProjectTable, - lookupSlug: ProjectRowSlugLookup, + lookupSlugMatches: ProjectRowSlugMatchLookup, slugIndexReady: boolean, selectedRepoIds: ReadonlySet ): GitHubProjectTable { const rows = table.rows.filter((row) => { const resolution = resolveSelectedProjectRowRepo({ row, - lookupSlug, + lookupSlugMatches, host: table.project.host, slugIndexReady, selectedRepoIds diff --git a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx index 6914f9b3aba..792799376ea 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx @@ -13,7 +13,10 @@ export function GitHubMarkdownComposerEditorPane({ const scrollContainerRef = useRef(null) return ( -
+
({ @@ -313,4 +314,81 @@ describe('HeroFlow height', () => { expect(refresh).toHaveFocus() }) + + it('demotes the network address picker to a disclosure on Orca Relay', async () => { + const props: React.ComponentProps = { + pairQrDataUrl: null, + pairingUrl: null, + pairingQrError: false, + relayMintFailure: null, + onUseLan: vi.fn(), + onRetryRelay: vi.fn(), + onCopyRelayDiagnostics: vi.fn(), + pairLoading: false, + connectionMode: 'automatic', + onConnectionModeChange: vi.fn(), + onRegeneratePairing: vi.fn(), + canGeneratePairing: true, + onCopyPairingCode: vi.fn(), + networkInterfaces: [], + customAddresses: [], + selectedAddress: undefined, + selectedAddressIsCustom: false, + onSelectedAddressChange: vi.fn(), + onCustomAddressSelect: vi.fn(), + onCustomAddressRemove: vi.fn(), + beforeCustomAddressChange: vi.fn().mockResolvedValue(true), + onRefreshNetworkInterfaces: vi.fn(), + refreshingNetworkInterfaces: false + } + const user = userEvent.setup() + const { rerender } = render() + expect(screen.queryByText('Network')).toBeNull() + expect(screen.queryByRole('button', { name: 'Refresh network interfaces' })).toBeNull() + + // Relay still advertises a LAN endpoint, so the picker must stay reachable. + await user.click(screen.getByRole('button', { name: /Also use a faster local path/i })) + expect(screen.getByText('Network')).toBeVisible() + expect(screen.getByRole('button', { name: 'Refresh network interfaces' })).toBeVisible() + expect(screen.getByText(/Optional\. Pick the Wi‑Fi or Tailscale address/i)).toBeVisible() + + rerender() + expect(screen.getByText('Network')).toBeVisible() + expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull() + expect(screen.getByRole('button', { name: 'Refresh network interfaces' })).toBeVisible() + }) + + it('keeps a custom address visible on Orca Relay', () => { + const address = 'host.example:6768' + const props: React.ComponentProps = { + pairQrDataUrl: null, + pairingUrl: null, + pairingQrError: false, + relayMintFailure: null, + onUseLan: vi.fn(), + onRetryRelay: vi.fn(), + onCopyRelayDiagnostics: vi.fn(), + pairLoading: false, + connectionMode: 'automatic', + onConnectionModeChange: vi.fn(), + onRegeneratePairing: vi.fn(), + canGeneratePairing: true, + onCopyPairingCode: vi.fn(), + networkInterfaces: [], + customAddresses: [address], + selectedAddress: address, + selectedAddressIsCustom: true, + onSelectedAddressChange: vi.fn(), + onCustomAddressSelect: vi.fn(), + onCustomAddressRemove: vi.fn(), + beforeCustomAddressChange: vi.fn().mockResolvedValue(true), + onRefreshNetworkInterfaces: vi.fn(), + refreshingNetworkInterfaces: false + } + render() + expect(screen.getByText('Network')).toBeVisible() + // Why: a trigger here could not collapse the pinned-open row, so it would be + // a dead control advertising aria-expanded it does not own. + expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull() + }) }) diff --git a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx index 2be436a8001..be041e25e75 100644 --- a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx +++ b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef } from 'react' -import { CircleAlert, Copy, RefreshCw } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { ChevronDown, CircleAlert, Copy, RefreshCw } from 'lucide-react' import { cn } from '../../lib/utils' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { NetworkInterfacePicker } from './NetworkInterfacePicker' import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions' @@ -106,6 +107,11 @@ export function MobileHeroPairingStep({ }): React.JSX.Element { const copyPairingCodeRef = useRef(null) const pairingWasReadyRef = useRef(pairingUrl != null && !pairLoading) + const usingRelay = connectionMode === 'automatic' + const [networkDisclosureOpen, setNetworkDisclosureOpen] = useState(false) + // A custom address is a deliberate override: show the row outright rather than + // behind a trigger that could not collapse it anyway. + const networkDisclosurePinned = selectedAddressIsCustom const emptyQrMessage = !pairLoading && pairQrDataUrl == null ? emptyPairingQrMessage({ @@ -126,6 +132,42 @@ export function MobileHeroPairingStep({ } }, [pairLoading, pairingUrl]) + const networkRow = ( +
+ + {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} + + + +
+ ) + return (
@@ -220,39 +262,48 @@ export function MobileHeroPairingStep({ ) : null}
-
- - {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} - - - -
+ + + + + {/* The row's own 18px gap moves to the Collapsible so the spacing + below stays identical whether the disclosure is open. `!` is + required: mobile-page.css is unlayered and so outranks Tailwind's + utilities layer on specificity ties. */} +
+

+ {translate( + 'auto.components.mobile.MobileHero.directAddressHint', + 'Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.' + )} +

+ {networkRow} +
+
+ + ) : ( + networkRow + )}
@@ -272,6 +323,7 @@ export function MobileHeroPairingStep({
diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx new file mode 100644 index 00000000000..6cddebcad90 --- /dev/null +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NetworkInterfacePicker } from './NetworkInterfacePicker' + +afterEach(cleanup) + +const noop = vi.fn() + +function renderPicker(props: { + networkInterfaces?: { name: string; address: string }[] + customAddresses?: string[] + selectedAddress?: string +}): void { + render( + + ) +} + +describe('NetworkInterfacePicker', () => { + // Why: a bridge-only host auto-advertises nothing, so the trigger has no selection to show while the + // bridge is still listed inside. Claiming "No interfaces found" there contradicts the list itself. + it('does not claim there are no interfaces when unselected options exist', () => { + renderPicker({ networkInterfaces: [{ name: 'docker0', address: '172.17.0.1' }] }) + + expect(screen.getByRole('combobox').textContent).toContain('No address selected') + }) + + it('reports an empty interface list when there is genuinely nothing to pick', () => { + renderPicker({}) + + expect(screen.getByRole('combobox').textContent).toContain('No interfaces found') + }) + + it('shows the selected interface instead of any placeholder', () => { + renderPicker({ + networkInterfaces: [{ name: 'en0', address: '192.168.1.24' }], + selectedAddress: '192.168.1.24' + }) + + expect(screen.getByRole('combobox').textContent).toContain('192.168.1.24 (en0)') + }) +}) diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index bd21eed05fb..99b970b4e96 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -55,6 +55,18 @@ export function NetworkInterfacePicker({ })), [customAddresses] ) + // Why: a host whose only interfaces are container bridges advertises no address by default, but the + // bridges are still listed here — "No interfaces found" would contradict the options right below it. + const placeholder = + options.length > 0 || customOptions.length > 0 + ? translate( + 'auto.components.mobile.NetworkInterfacePicker.no-address-selected', + 'No address selected' + ) + : translate( + 'auto.components.settings.MobileNetworkInterfaceSection.b2c384cfd6', + 'No interfaces found' + ) return ( { expect(screen.getByText(/TCP port 6768/i)).toBeInTheDocument() }) + it('reassures on Relay that pairing works without the firewall rule', async () => { + const getWindowsFirewallStatus = vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + blockingRuleDetected: false, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + setMobileApi({ getWindowsFirewallStatus }) + const { rerender } = render( + + ) + expect(await screen.findByText(/allow phone connections through/i)).toBeInTheDocument() + expect(screen.getByText(/still works over Orca Relay/i)).toBeInTheDocument() + + rerender() + expect(screen.queryByText(/still works over Orca Relay/i)).not.toBeInTheDocument() + }) + it('repairs only after explicit user action and hides after success', async () => { const repairWindowsFirewall = vi.fn().mockResolvedValue({ ok: true }) const getWindowsFirewallStatus = vi diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx index fb9d1827673..5ac7f5caac0 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx @@ -10,12 +10,15 @@ import { cn } from '../../lib/utils' type WindowsFirewallNoticeProps = { pairingReady: boolean address?: string + /** Relay offers still carry a LAN endpoint, but a blocked one is not fatal. */ + usingRelay?: boolean className?: string } export function WindowsFirewallNotice({ pairingReady, address, + usingRelay = false, className }: WindowsFirewallNoticeProps): React.JSX.Element | null { const [status, setStatus] = useState(null) @@ -173,6 +176,14 @@ export function WindowsFirewallNotice({ { port: firewallStatus.port } )}

+ {usingRelay ? ( +

+ {translate( + 'auto.components.mobile.WindowsFirewallNotice.relay-note', + 'Pairing still works over Orca Relay — allowing this only adds the faster local connection.' + )} +

+ ) : null}
{networkIsPublic ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 33a8ddecbcc..2fb458166ec 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import type { NativeChatSession } from '../../../../shared/native-chat-types' -import { useNativeChatLiveSession } from './use-native-chat-live-session' +import { useNativeChatRetainedSession } from './use-native-chat-retained-session' import { selectNativeChatViewState } from './native-chat-view-state' import { NativeChatMessageList } from './NativeChatMessageList' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' @@ -131,7 +131,7 @@ function NativeChatResolvedView({ const runtimeEnvironmentId = useAppStore((s) => selectNativeChatRuntimeEnvironmentId(s, terminalTabId) ) - const session = useNativeChatLiveSession({ + const session = useNativeChatRetainedSession({ paneKey, agent, sessionId, @@ -430,6 +430,7 @@ function NativeChatResolvedView({ send={interactiveSend} canSend={canSend} messages={sessionAfterCommandBoundaries.messages} + transcriptSettled={session.readPhase === 'ready'} onShowingQuestionChange={setQuestionActive} answerInputRef={questionAnswerInputRef} /> diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts index a41d62eeb7a..82f9280bffb 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -1,7 +1,277 @@ import { describe, expect, it } from 'vitest' import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' +// Captured from a live `claude` 2.1.220 startup frame by replaying the PTY +// bytes through @xterm/headless and calling serializeAddon.serialize({ +// scrollback: 0 }) — byte-for-byte what TerminalPane hands the scraper. The two +// widths matter: at 100 columns the frame keeps its version and prints billing +// on the model row, at 60 it drops the version and wraps billing to its own row. + +const FRESH_SESSION_100_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭───\u001b[1CClaude Code\u001b[1C\u001b[38;2;153;153;153mv2.1.220\u001b[1C\u001b[38;2;215;119;87m─────────────────────────────────────────────────────────────────────────╮\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[22;1mTips for getting started\u001b[22C\u001b[22m│\r', + '│\u001b[19C\u001b[39;1mWelcome back!\u001b[18C\u001b[38;2;215;119;87;22;2m│\u001b[1C\u001b[0mRun\u001b[1C/init\u001b[1Cto\u001b[1Ccreate\u001b[1Ca\u001b[1CCLAUDE.md\u001b[1Cfile\u001b[1Cwith\u001b[1Cin…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[22m─────────────────────────────────────────────\u001b[1C│\r', + "│\u001b[21C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[21C\u001b[2m│\u001b[1C\u001b[22;1mWhat's new\u001b[36C\u001b[22m│\r", + '│\u001b[21C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[20C\u001b[2m│\u001b[1C\u001b[0mBug\u001b[1Cfixes\u001b[1Cand\u001b[1Creliability\u001b[1Cimprovements\u001b[8C\u001b[38;2;215;119;87m│\r', + '│\u001b[21C ▘▘ ▝▝ \u001b[20C\u001b[2m│\u001b[1C\u001b[0mAdded\u001b[1CClaude\u001b[1COpus\u001b[1C5\u001b[1C(`claude-opus-5`),\u001b[1Cnow\u001b[1Ct…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[0mAdded\u001b[1C`sandbox.network.strictAllowlist`\u001b[1Csett…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[3C\u001b[38;2;153;153;153mFable 5 with high effort · API Usage Billing\u001b[3C\u001b[38;2;215;119;87;2m│\u001b[1C\u001b[38;2;153;153;153;22;3m/release-notes for more\u001b[23C\u001b[38;2;215;119;87;23m│\r', + '│\u001b[19C\u001b[38;2;153;153;153m/private/tmp\u001b[19C\u001b[38;2;215;119;87;2m│\u001b[47C\u001b[22m│\r', + '╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set and …\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "fix typecheck errors"\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker\u001b[38;2;153;153;153m · restart with CLAUDE_COD…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mFable 5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const FRESH_SESSION_60_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭─ Claude Code ────────────────────────────────────────────╮\r', + '│\u001b[58C│\r', + '│\u001b[22C\u001b[39;1mWelcome back!\u001b[23C\u001b[38;2;215;119;87;22m│\r', + '│\u001b[58C│\r', + '│\u001b[25C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[25C│\r', + '│\u001b[25C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[24C│\r', + '│\u001b[25C ▘▘ ▝▝ \u001b[24C│\r', + '│\u001b[58C│\r', + '│\u001b[17C\u001b[38;2;153;153;153mFable 5 with high effort\u001b[17C\u001b[38;2;215;119;87m│\r', + '│\u001b[20C\u001b[38;2;153;153;153mAPI Usage Billing\u001b[21C\u001b[38;2;215;119;87m│\r', + '│\u001b[23C\u001b[38;2;153;153;153m/private/tmp\u001b[23C\u001b[38;2;215;119;87m│\r', + '│\u001b[58C│\r', + '╰──────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_AP…\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "refactor "\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mFable 5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const OPTION_LESS_MODEL_60_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭─ Claude Code ────────────────────────────────────────────╮\r', + '│\u001b[58C│\r', + '│\u001b[22C\u001b[39;1mWelcome back!\u001b[23C\u001b[38;2;215;119;87;22m│\r', + '│\u001b[58C│\r', + '│\u001b[25C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[25C│\r', + '│\u001b[25C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[24C│\r', + '│\u001b[25C ▘▘ ▝▝ \u001b[24C│\r', + '│\u001b[58C│\r', + '│\u001b[24C\u001b[38;2;153;153;153mHaiku 4.5\u001b[25C\u001b[38;2;215;119;87m│\r', + '│\u001b[20C\u001b[38;2;153;153;153mAPI Usage Billing\u001b[21C\u001b[38;2;215;119;87m│\r', + '│\u001b[23C\u001b[38;2;153;153;153m/private/tmp\u001b[23C\u001b[38;2;215;119;87m│\r', + '│\u001b[58C│\r', + '╰──────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_AP…\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "how does work?"\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mHaiku 4.5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const EFFORT = { + id: 'effort', + label: 'Effort', + kind: { + type: 'select' as const, + choices: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ], + defaultValue: 'high' + }, + apply: {} +} + describe('Claude terminal session option detection', () => { + it('reads the model from a real 100-column startup frame', () => { + // The descriptor sits eight rows under the version, below the welcome art. + expect(readClaudeSessionOptionsFromTerminalScreen(FRESH_SESSION_100_COLS)).toEqual({ + model: 'fable', + effort: 'high' + }) + }) + + it('reads a real 60-column frame that drops the version and wraps billing', () => { + expect(readClaudeSessionOptionsFromTerminalScreen(FRESH_SESSION_60_COLS)).toEqual({ + model: 'fable', + effort: 'high' + }) + }) + + it('reads a real narrow frame whose option-less model carries no metadata', () => { + // Why: `Haiku 4.5` has no effort control and its billing wrapped to the next + // row, so nothing marks the row as a model — only its position above the + // working directory does. + expect(readClaudeSessionOptionsFromTerminalScreen(OPTION_LESS_MODEL_60_COLS)).toEqual({ + model: 'haiku' + }) + }) + + it('reads an option-less model above a Windows working directory', () => { + const screen = [ + '╭─ Claude Code ───╮', + '│Haiku 4.5│', + '│API Usage Billing│', + '│C:\\work\\repo│', + '╰─────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ model: 'haiku' }) + }) + + it('reports nothing rather than release-notes prose when the model row is bare', () => { + // Why: a custom model with its billing wrapped away carries no descriptor + // metadata, so nothing in the frame identifies it. The panel shares rows + // with the art and its borders collide (`││`) — reading past them would + // make panel prose win the search and report a sentence as the model. + const screen = [ + '╭─── Claude Codev2.1.220 ───────────────────────╮', + '││Runs faster · uses fewer tokens│', + '│ ▐▛███▜▌│What‽s new│', + '│acme-frontier│', + '│API Usage Billing│', + '│/repo││', + '╰───────────────────────────────────────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toBeNull() + }) + + it('reports nothing when a partial frame only contains wrapped billing', () => { + const screen = [ + '╭─ Claude Code ───╮', + '│API Usage Billing│', + '│/repo│', + '╰─────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toBeNull() + }) + + it('does not reject a bare custom model whose name mentions billing', () => { + const screen = ['╭─ Claude Code ───╮', '│billing-model│', '│/repo│', '╰─────────────╯'].join( + '\r\n' + ) + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'billing-model' + }) + }) + + // The exact catalog `claude -p` list_models returns on CLI 2.1.220: note the + // host offers no plain `opus`, and `sonnet` and `sonnet[1m]` are distinct. + const DISCOVERED = [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [EFFORT] }, + { id: 'sonnet', label: 'Sonnet', options: [EFFORT] }, + { id: 'sonnet[1m]', label: 'Sonnet 5 (1M context)', options: [EFFORT] }, + { id: 'fable', label: 'Fable', options: [EFFORT] }, + { id: 'haiku', label: 'Haiku', options: [] } + ] + + function frame(descriptor: string): string { + return `Claude Code v2.1.220\r\n${descriptor}\r\n~/repo` + } + + it('resolves to the host id the picker lists, not the seed family', () => { + // Why: `opus` is not offered on this host, so reporting it would select a + // row the picker has to invent. + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Opus 5 (1M context) with high effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'opus[1m]', effort: 'high' }) + }) + + it('prefers the most specific variant when a family also matches', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Sonnet 5 (1M context) with low effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'sonnet[1m]', effort: 'low' }) + }) + + it('keeps the plain family when the frame names no variant', () => { + // Why: `Sonnet 5` must not be captured by the 1M-context row just because + // that row's label happens to start with the same two tokens. + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Sonnet 5 with medium effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'sonnet', effort: 'medium' }) + }) + + it('falls back to a seed family the host list no longer offers', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Opus 4.8 with high effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'opus', effort: 'high' }) + }) + + it('still reports a custom model when the host list cannot name it', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('company/my-haiku-v2 · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'company/my-haiku-v2' }) + }) + + it('reads an effort suffix the frame truncated to an ellipsis', () => { + const screen = + 'Claude Code v2.1.220\r\n' + + 'Opus 5 (1M context) with high… · API Usage Billing\r\n' + + '~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus', + effort: 'high' + }) + }) + it('reads the current model and effort from Claude header chrome', () => { const screen = '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + @@ -63,4 +333,60 @@ describe('Claude terminal session option detection', () => { readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.') ).toBeNull() }) + + it('recovers a custom model name that is not in the catalog', () => { + const screen = + '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + + '\u001b[38;2;102;102;102mmy-custom-model with high effort · API Usage Billing\r\n' + + '~/repo' + + // Custom models have no catalog options, so effort is intentionally dropped. + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'my-custom-model' + }) + }) + + it('recovers a custom model with no effort suffix', () => { + const screen = 'Claude Code v2.1.211\r\ncompany/internal-opus · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'company/internal-opus' + }) + }) + + it('keeps a custom model containing a catalog label', () => { + const screen = 'Claude Code v2.1.211\r\ncompany/my-haiku-v2 · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'company/my-haiku-v2' + }) + }) + + it('keeps a custom model whose name only starts with a catalog family', () => { + // Why: the family must match as a whole leading word, not a bare prefix, or + // every vendor slug built off a family name would be claimed as that model. + const screen = 'Claude Code v2.1.211\r\nopus-internal-v3 · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus-internal-v3' + }) + }) + + it('ignores catalog labels outside the descriptor row', () => { + const screen = 'Claude Code v2.1.211\r\nacme-frontier · API Usage Billing\r\n~/work/Haiku' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'acme-frontier' + }) + }) + + it('recovers a custom model when xterm joins the descriptor to the header', () => { + const screen = + '\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211' + + '▝▜█████▛▘acme-frontier with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'acme-frontier' + }) + }) }) diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts index 7a1e5b8fbd8..3b8ad86a47a 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -1,4 +1,7 @@ -import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { + getAgentSessionOptionCatalog, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { stripScrollbackAnsi } from './native-chat-scrape-fallback' @@ -11,41 +14,219 @@ const EFFORT_ID_BY_LABEL: Record = { max: 'max' } +// Why: narrow panes elide the trailing `effort` word to an ellipsis, so the +// level itself is the only part of the suffix we can rely on. +const CLAUDE_MODEL_EFFORT = + /\bwith\s+(extra high|xhigh|medium|high|low|max)(?:\s+effort\b|\s*…|\s*$)/i + +// Claude's startup frame. Chat text uses an em dash rather than these box +// characters, so a leading corner is safe proof that a row is chrome. +const FRAME_TOP = '╭' +const FRAME_BOTTOM = '╰' +const FRAME_COLUMN = '│' + +/** Rows of the visible screen, ANSI-stripped and whitespace-collapsed. */ function normalizedScreenLines(screen: string): string[] { return stripScrollbackAnsi(screen) .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) } +function isClaudeHeaderRow(line: string): boolean { + // No trailing word boundary: serialization drops the gap before the version, + // leaving `Claude Codev2.1.220`. + if (!/\bClaude Code/i.test(line)) { + return false + } + // Why: panes under ~70 columns drop the version from the frame entirely, so + // accept the frame corner as the alternative proof this is Claude's chrome. + // xterm serialization also drops cursor-positioning cells between the product + // name and version, producing `Claude Codev2.1.211`. + return /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) || line.startsWith(FRAME_TOP) +} + +/** The frame's leftmost cell on a row. Reading up to the next column border + * keeps the release-notes panel out: on rows whose left cell is empty the + * borders collide (`││`) and this yields nothing rather than the panel text. */ +function frameCell(line: string): string { + if (line.startsWith(FRAME_COLUMN)) { + const afterBorder = line.slice(FRAME_COLUMN.length) + const columnEnd = afterBorder.indexOf(FRAME_COLUMN) + return (columnEnd < 0 ? afterBorder : afterBorder.slice(0, columnEnd)).trim() + } + // Unframed buffers put the descriptor on the row directly, behind box art. + return line.replace(/^[^A-Za-z0-9]+/, '').trim() +} + +/** Whether a cell carries the metadata Claude only prints on the model row, so + * ordinary frame text is never mistaken for a model. */ +function isModelDescriptorCell(cell: string): boolean { + return cell.includes('·') || CLAUDE_MODEL_EFFORT.test(cell) +} + +function isWorkingDirectoryCell(cell: string): boolean { + return ( + cell.startsWith('/') || + cell.startsWith('~') || + /^[A-Za-z]:[\\/]/.test(cell) || + /^\\\\[^\\]/.test(cell) + ) +} + +/** + * Claude prints the model descriptor near the bottom of its startup frame, with + * the welcome art and the release-notes panel in between — it is not adjacent to + * the version row. Search the frame from the bottom up so the panel's own text + * can never win over the descriptor. + */ +function claudeModelDescriptorCell(lines: string[], headerIndex: number): string | null { + const frameBottom = lines.findIndex( + (line, index) => index > headerIndex && line.startsWith(FRAME_BOTTOM) + ) + // Why: an unframed buffer gives no bottom to bound the search, so keep the + // original tight window rather than reaching into conversation text. + const lastRow = frameBottom > 0 ? frameBottom - 1 : Math.min(headerIndex + 2, lines.length - 1) + for (let index = lastRow; index > headerIndex; index -= 1) { + const cell = frameCell(lines[index] ?? '') + if (isModelDescriptorCell(cell)) { + return cell + } + } + // xterm serialization can join the descriptor onto the version row itself. + const joined = frameCell((lines[headerIndex] ?? '').replace(/^.*?\bClaude Code\s*v?[\d.]*/i, '')) + if (isModelDescriptorCell(joined)) { + return joined + } + return frameBottom > 0 ? modelRowAboveWorkingDirectory(lines, headerIndex, frameBottom) : null +} + +/** + * A model with no effort control whose billing wrapped away — `Haiku 4.5` on a + * narrow pane — leaves a bare name that no metadata identifies. Claude always + * closes the frame with the working directory, and prints at most the + * descriptor plus a wrapped billing line above it, so walk up from there. + */ +function modelRowAboveWorkingDirectory( + lines: string[], + headerIndex: number, + frameBottom: number +): string | null { + let workingDirectory = -1 + for (let index = frameBottom - 1; index > headerIndex; index -= 1) { + const cell = frameCell(lines[index] ?? '') + if (isWorkingDirectoryCell(cell)) { + workingDirectory = index + break + } + } + if (workingDirectory < 0) { + return null + } + let top = workingDirectory + while (top - 1 > headerIndex && frameCell(lines[top - 1] ?? '')) { + top -= 1 + } + // Anything taller than descriptor + billing is the welcome art, not a model. + if (top >= workingDirectory || workingDirectory - top > 2) { + return null + } + const cell = frameCell(lines[top] ?? '') + return /^API Usage Billing$/i.test(cell) ? null : cell +} + +/** The displayed model name, without the effort suffix or billing tail. */ +function parseClaudeModelName(cell: string): string | null { + // split always yields a non-empty first segment for a non-empty cell. + const beforeBilling = cell.split('·')[0]! + const effort = beforeBilling.match(CLAUDE_MODEL_EFFORT) + const name = ( + effort?.index === undefined ? beforeBilling : beforeBilling.slice(0, effort.index) + ).trim() + return name || null +} + +function modelNameTokens(value: string): string[] { + return value.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? [] +} + +/** + * Whether a catalog label names the model the frame reported. The frame prints + * the resolved model ("Opus 5 (1M context)") while labels name the alias + * ("Opus (1M context)"), so the family has to lead as a whole word and the + * label's remaining tokens have to appear in order. Requiring a whole word is + * what keeps `company/my-haiku-v2` and `opus-internal-v3` out of the catalog; + * requiring the rest is what stops `Sonnet` from claiming the 1M-context row. + */ +function labelNamesReportedModel(reportedModel: string, label: string): boolean { + const labelTokens = modelNameTokens(label) + const family = labelTokens[0] + if (!family) { + return false + } + const name = reportedModel.toLowerCase() + if (name !== family && !name.startsWith(`${family} `)) { + return false + } + const nameTokens = modelNameTokens(name) + let cursor = 1 + for (const token of labelTokens.slice(1)) { + const found = nameTokens.indexOf(token, cursor) + if (found < 0) { + return false + } + cursor = found + 1 + } + return true +} + +/** The most specific catalog entry naming this model, preferring the host's + * discovered list so variants like `opus[1m]` win over the seed family. */ +function findClaudeCatalogModel( + reportedModel: string, + models: readonly CatalogModel[] | undefined +): CatalogModel | undefined { + const seeded = getAgentSessionOptionCatalog('claude')?.models ?? [] + for (const candidates of [models ?? [], seeded]) { + const matches = candidates.filter(({ label }) => labelNamesReportedModel(reportedModel, label)) + const best = matches.sort( + (left, right) => modelNameTokens(right.label).length - modelNameTokens(left.label).length + )[0] + if (best) { + return best + } + } + return undefined +} + +/** + * @param models the host's live model list. Pass the discovered catalog so the + * reported id is one the picker actually lists; omitting it falls back to the + * version-neutral seed families. + */ export function readClaudeSessionOptionsFromTerminalScreen( - screen: string | null | undefined + screen: string | null | undefined, + models?: readonly CatalogModel[] ): Record | null { if (!screen) { return null } const lines = normalizedScreenLines(screen) - const headerIndex = lines.findIndex((line) => - // xterm serialization can remove cursor-positioning cells between the - // product name and version, producing `Claude Codev2.1.211`. - /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) - ) + const headerIndex = lines.findIndex(isClaudeHeaderRow) if (headerIndex < 0) { return null } - // Why: only Claude's fixed header rows describe live state; conversation - // text and old command confirmations elsewhere in the buffer can be stale. - const header = lines.slice(headerIndex, headerIndex + 3).join(' ') - const catalog = getAgentSessionOptionCatalog('claude') - const model = [...(catalog?.models ?? [])] - .sort((left, right) => right.label.length - left.label.length) - .find(({ label }) => header.toLowerCase().includes(label.toLowerCase())) - if (!model) { + const descriptorCell = claudeModelDescriptorCell(lines, headerIndex) + const reportedModel = descriptorCell ? parseClaudeModelName(descriptorCell) : null + if (!reportedModel) { return null } + const model = findClaudeCatalogModel(reportedModel, models) + if (!model) { + // A custom model carries no catalog options, so no effort is reported. + return { model: reportedModel } + } const result: Record = { model: model.id } - const effortLabel = header.match( - /\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i - )?.[1] + const effortLabel = descriptorCell?.match(CLAUDE_MODEL_EFFORT)?.[1] const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined if (effort && model.options.some((option) => option.id === 'effort')) { result.effort = effort diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index 50d19d8821d..9c77e05992a 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -90,6 +90,21 @@ describe('canToggleNativeChat', () => { ).toBe(false) }) + // Why: omp discloses no hook transcript path either, so its session file is + // only reachable when this process can read the agent's disk. + it('rejects Model-A SSH omp but accepts it local and runtime-owned', () => { + const forConnection = (connectionId: string | null): boolean => + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'omp', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + }) + expect(forConnection('ssh-target-1')).toBe(false) + expect(forConnection(null)).toBe(true) + expect(forConnection('runtime-ssh-env-1')).toBe(true) + }) + it('lets an existing Model-A SSH Grok chat toggle back to terminal', () => { expect( canToggleNativeChat({ diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 3d2be38e376..11b9e5af7ad 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -1,6 +1,9 @@ import type { Tab, TuiAgent } from '../../../../shared/types' import type { AgentType } from '../../../../shared/agent-status-types' -import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from '@/lib/native-chat-supported-agent' export { isNativeChatSupportedAgent } @@ -46,7 +49,10 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean return true } const agent = input.detectedAgent ?? input.launchAgent ?? input.resolvedAgent - if (agent === 'grok' && input.nativeChatTranscriptIsLocalReadable !== true) { + if ( + nativeChatRequiresLocalTranscript(agent) && + input.nativeChatTranscriptIsLocalReadable !== true + ) { return false } return isNativeChatSupportedAgent(agent) diff --git a/src/renderer/src/components/native-chat/native-chat-diff.test.ts b/src/renderer/src/components/native-chat/native-chat-diff.test.ts index 598b6b97d4a..229edc1e4bd 100644 --- a/src/renderer/src/components/native-chat/native-chat-diff.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-diff.test.ts @@ -60,4 +60,136 @@ describe('diffFromText', () => { expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['new']) expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['old']) }) + + it('treats a removed line whose content starts with -- as a deletion', () => { + const diff = diffFromText( + '@@ -1,4 +1,1 @@\n SELECT 1;\n--- legacy comment\n-SELECT 2;\n-SELECT 3;' + ) + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,4 +1,1 @@' }, + { kind: 'context', text: ' SELECT 1;' }, + { kind: 'del', text: '-- legacy comment' }, + { kind: 'del', text: 'SELECT 2;' }, + { kind: 'del', text: 'SELECT 3;' } + ]) + }) + + it('treats an added line whose content starts with ++ as an addition', () => { + const diff = diffFromText('@@ -1,1 +1,3 @@\n ctx\n+++flag\n+normal') + expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['++flag', 'normal']) + }) + + it('classifies a C-style --i deletion inside a hunk', () => { + const diff = diffFromText('@@ -1,3 +1,2 @@\n int i;\n---i;\n-return i;') + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['--i;', 'return i;']) + }) + + it('keeps real file headers as meta in full git diff output', () => { + const diff = diffFromText( + [ + 'diff --git a/a.sql b/a.sql', + 'index 9eff25c..33791bf 100644', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,3 +1,2 @@', + ' SELECT 1;', + '--- legacy comment', + ' SELECT 2;' + ].join('\n') + ) + expect(diff?.filter((l) => l.kind === 'meta').map((l) => l.text)).toEqual([ + 'diff --git a/a.sql b/a.sql', + 'index 9eff25c..33791bf 100644', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,3 +1,2 @@' + ]) + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['-- legacy comment']) + }) + + it('detects the second file headers in a multi-file diff', () => { + const diff = diffFromText( + [ + 'diff --git a/a.sql b/a.sql', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,2 +1,1 @@', + '--- legacy comment', + ' SELECT 2;', + 'diff --git a/b.lua b/b.lua', + '--- a/b.lua', + '+++ b/b.lua', + '@@ -1,2 +1,1 @@', + '--- lua comment', + ' print(1)' + ].join('\n') + ) + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual([ + '-- legacy comment', + '-- lua comment' + ]) + }) + + it('renders a single-line change once hunk headers prove it is a diff', () => { + expect(diffFromText('@@ -1,2 +1,1 @@\n SELECT 1;\n--- legacy comment')).not.toBeNull() + // Without diff structure the two-marker prose guard still applies. + expect(diffFromText('-- legacy comment')).toBeNull() + }) + + it('classifies a --- content deletion in header-less agent-tool output', () => { + const diff = diffFromText('---sql comment\n+new\n-more context') + expect(diff).toEqual([ + { kind: 'del', text: '--sql comment' }, + { kind: 'add', text: 'new' }, + { kind: 'del', text: 'more context' } + ]) + }) + + it('does not read an adjacent --x / ++y content pair as a file header', () => { + const diff = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---note\n+++flag') + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '--note' }, + { kind: 'add', text: '++flag' } + ]) + }) + + it('does not read a spaced -- / ++ content pair inside a hunk as a file header', () => { + const diff = diffFromText('@@ -1,2 +1,2 @@\n ctx\n--- note\n+++ flag') + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '-- note' }, + { kind: 'add', text: '++ flag' } + ]) + }) + + it('handles /dev/null headers for an added file', () => { + const diff = diffFromText('--- /dev/null\n+++ b/x.sql\n+-- new comment\n+SELECT 1;') + expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual([ + '-- new comment', + 'SELECT 1;' + ]) + expect(diff?.filter((l) => l.kind === 'del')).toEqual([]) + }) + + it('leaves a YAML document separator out of the marker count', () => { + expect(diffFromText('---\na: 1\n---\nb: 2')).toBeNull() + }) + + it('keeps a Markdown thematic break as meta rather than a deletion', () => { + const diff = diffFromText('Intro\n-----\n- alpha\n- beta') + expect(diff).toEqual([ + { kind: 'context', text: 'Intro' }, + { kind: 'meta', text: '-----' }, + { kind: 'del', text: ' alpha' }, + { kind: 'del', text: ' beta' } + ]) + }) + + it('still reads a bare --- inside a hunk as a deletion', () => { + const diff = diffFromText('@@ -1,3 +1,1 @@\n ctx\n---\n-SELECT 1;') + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['--', 'SELECT 1;']) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts new file mode 100644 index 00000000000..17b5801b05a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { + NativeChatLiveSession, + UseNativeChatLiveSessionArgs +} from './use-native-chat-live-session' + +const { liveSession } = vi.hoisted(() => ({ liveSession: vi.fn() })) + +vi.mock('./use-native-chat-live-session', () => ({ useNativeChatLiveSession: liveSession })) + +import { useNativeChatRetainedSession } from './use-native-chat-retained-session' + +const ARGS: UseNativeChatLiveSessionArgs = { + paneKey: 'tab:leaf', + agent: 'claude', + sessionId: 'session', + runtimeEnvironmentId: 'owner-a' +} + +function message(id: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [], timestamp: 0, source: 'transcript' } +} + +function session( + readPhase: NativeChatLiveSession['readPhase'], + messages: NativeChatMessage[], + sessionId: string | null = 'session' +): NativeChatLiveSession { + return { + agent: 'claude', + sessionId, + messages, + status: readPhase === 'loading' ? 'loading' : 'ready', + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn(), + readPhase + } +} + +describe('useNativeChatRetainedSession', () => { + let root: Root | null = null + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatRetainedSession(props) + return null + } + + async function render(props: UseNativeChatLiveSessionArgs): Promise { + if (!root) { + root = createRoot(document.createElement('div')) + } + await act(async () => root?.render(createElement(Probe, props))) + } + + afterEach(() => { + act(() => root?.unmount()) + root = null + latest = null + liveSession.mockReset() + }) + + it('keeps settled messages during a same-identity rebind', async () => { + liveSession.mockReturnValue(session('ready', [message('settled')])) + await render(ARGS) + + liveSession.mockReturnValue(session('loading', [])) + await render(ARGS) + + expect(latest?.readPhase).toBe('loading') + expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled']) + }) + + it('misses retained messages when the source owner changes', async () => { + liveSession.mockReturnValue(session('ready', [message('owner-a')])) + await render(ARGS) + + liveSession.mockReturnValue(session('loading', [])) + await render({ ...ARGS, runtimeEnvironmentId: 'owner-b' }) + + expect(latest?.readPhase).toBe('loading') + expect(latest?.messages).toEqual([]) + }) + + it('does not overwrite retention with the session-less view', async () => { + liveSession.mockReturnValue(session('ready', [message('settled')])) + await render(ARGS) + + liveSession.mockReturnValue(session('ready', [], null)) + await render({ ...ARGS, sessionId: null }) + liveSession.mockReturnValue(session('loading', [])) + await render(ARGS) + + expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled']) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts new file mode 100644 index 00000000000..e10ccecf5aa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react' +import { + createNativeChatTranscriptRetention, + encodeNativeChatTranscriptIdentity +} from '../../../../shared/native-chat-transcript-retention' +import { + useNativeChatLiveSession, + type NativeChatLiveSession, + type UseNativeChatLiveSessionArgs +} from './use-native-chat-live-session' + +/** Keeps one committed conversation visible while its exact source rebinds. */ +export function useNativeChatRetainedSession( + args: UseNativeChatLiveSessionArgs +): NativeChatLiveSession { + const session = useNativeChatLiveSession(args) + const identity = encodeNativeChatTranscriptIdentity([ + args.paneKey, + args.runtimeEnvironmentId ?? null, + args.agent, + args.sessionId, + args.transcriptPath ?? null + ]) + const activeIdentityRef = useRef(identity) + const retentionRef = useRef(createNativeChatTranscriptRetention()) + const sessionMatchesIdentity = activeIdentityRef.current === identity + const readPhase = sessionMatchesIdentity ? session.readPhase : 'loading' + + useEffect(() => { + activeIdentityRef.current = identity + }, [identity]) + useEffect(() => { + if (sessionMatchesIdentity && args.sessionId !== null && session.readPhase === 'ready') { + retentionRef.current.capture(identity, session.messages) + } + }, [args.sessionId, identity, session.messages, session.readPhase, sessionMatchesIdentity]) + + const messages = retentionRef.current.visible({ + identity, + messages: session.messages, + settled: readPhase === 'ready', + loading: readPhase === 'loading' + }) + if (messages === session.messages && readPhase === session.readPhase) { + return session + } + return { + ...session, + messages, + readPhase, + ...(sessionMatchesIdentity ? {} : { status: 'loading' as const, error: undefined }) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts new file mode 100644 index 00000000000..a24d1b8f87f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment happy-dom + +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment' + +const discoverModels = vi.fn<() => Promise>() + +vi.mock('./native-chat-session-option-discovery', () => ({ + resolveNativeChatModelDiscoveryContext: () => ({ hostKey: 'host', runtime: null }), + discoverNativeChatCatalogModels: () => discoverModels() +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign(() => undefined, { + getState: () => ({ settings: {}, updateSettings: async () => undefined }) + }) +})) + +import { useNativeChatSessionOptions } from './use-native-chat-session-options' + +// A 1M-context Opus session: the frame names the resolved model while the +// host's discovered catalog names the alias. +const CLAUDE_SCREEN = + 'Claude Code v2.1.220\r\nOpus 5 (1M context) with high effort · API Usage Billing\r\n~/repo' + +const DISCOVERED: CatalogModel[] = [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [] }, + { id: 'haiku', label: 'Haiku', options: [] } +] + +function modelDescriptor(snapshot: { id: string; kind: unknown }[]): { + currentValue?: string + choices: { value: string }[] +} { + const model = snapshot.find((descriptor) => descriptor.id === 'model') + return model?.kind as { currentValue?: string; choices: { value: string }[] } +} + +describe('useNativeChatSessionOptions model reporting', () => { + beforeEach(() => { + clearNativeChatModelEnrichmentForTests() + discoverModels.mockReset() + Object.defineProperty(window, 'api', { configurable: true, value: undefined }) + }) + + it('re-resolves the reported model against models discovered after the read', async () => { + // Why: discovery is async, so the first scrape can only reach the seed. If + // the reported id were left at the family the picker would show a row it + // had to invent — and by then the frame may have scrolled out of the buffer, + // so re-reading the screen is not an option. + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + + // Stable identity, and the frame scrolls out of the buffer before discovery + // lands — so nothing but the cached screen can drive the re-resolution. + let frameVisible = true + const readTerminalScreen = (): string | null => + frameVisible ? CLAUDE_SCREEN : 'conversation has scrolled past the frame' + const dispatchCommand = vi.fn() + + const { result } = renderHook(() => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-1', + targetPtyId: 'pty-1', + dispatchCommand, + readTerminalScreen + }) + ) + + await waitFor(() => expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus')) + + frameVisible = false + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus[1m]') + ) + // The invented family row is gone: every choice is one the host listed. + expect(modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value)).toEqual([ + 'opus[1m]', + 'haiku' + ]) + }) + + it('does not re-resolve a late snapshot from the previous pty', async () => { + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + let resolveOldSnapshot: (snapshot: { data: string; alternateScreen: false }) => void = () => {} + const oldSnapshot = new Promise<{ data: string; alternateScreen: false }>((resolve) => { + resolveOldSnapshot = resolve + }) + const getMainBufferSnapshot = vi + .fn() + .mockReturnValueOnce(oldSnapshot) + .mockReturnValueOnce(new Promise(() => {})) + Object.defineProperty(window, 'api', { + configurable: true, + value: { pty: { getMainBufferSnapshot } } + }) + + const dispatchCommand = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-1', + targetPtyId, + dispatchCommand, + readTerminalScreen: () => null + }), + { initialProps: { targetPtyId: 'pty-old' } } + ) + + rerender({ targetPtyId: 'pty-new' }) + resolveOldSnapshot({ data: CLAUDE_SCREEN, alternateScreen: false }) + await Promise.resolve() + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect( + modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value) + ).toEqual(['opus[1m]', 'haiku']) + ) + expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined() + }) + + it('clears a previously reported screen when the target pty changes', async () => { + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + const getMainBufferSnapshot = vi + .fn() + .mockResolvedValueOnce({ data: CLAUDE_SCREEN, alternateScreen: false }) + .mockReturnValueOnce(new Promise(() => {})) + Object.defineProperty(window, 'api', { + configurable: true, + value: { pty: { getMainBufferSnapshot } } + }) + + const dispatchCommand = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-reset', + targetPtyId, + dispatchCommand, + readTerminalScreen: () => null + }), + { initialProps: { targetPtyId: 'pty-reported' } } + ) + await waitFor(() => expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus')) + + rerender({ targetPtyId: 'pty-empty' }) + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect( + modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value) + ).toEqual(['opus[1m]', 'haiku']) + ) + expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts index b0dfe6503fe..a722a1ffc98 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react' +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' @@ -36,6 +36,9 @@ export function useNativeChatSessionOptions(args: { } { const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } = args + // The screen text that last parsed into reported values, so a later model + // discovery can re-resolve it against the host's real ids. + const reportedScreenRef = useRef(null) const discoveryContext = useMemo( () => resolveNativeChatModelDiscoveryContext(terminalTabId), [terminalTabId] @@ -47,8 +50,16 @@ export function useNativeChatSessionOptions(args: { return null } const scopeKey = targetPtyId ?? terminalTabId + const discoveredModels = discoveryContext + ? readNativeChatEnrichedModels(agent, discoveryContext.hostKey) + : null const reportedValues = - agent === 'claude' ? readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) : null + agent === 'claude' + ? readClaudeSessionOptionsFromTerminalScreen( + readTerminalScreen?.(), + discoveredModels ?? undefined + ) + : null let settingsWrite = Promise.resolve() return createNativeChatPtySessionOptions({ agent, @@ -57,12 +68,7 @@ export function useNativeChatSessionOptions(args: { // Why: the catalog seed carries version-neutral family labels, so it is // safe on every host while the once-per-host probe runs or after it fails // — without it the whole picker would pop in late or never appear. - ...(discoveryContext - ? { - initialModels: - readNativeChatEnrichedModels(agent, discoveryContext.hostKey) ?? undefined - } - : {}), + ...(discoveryContext ? { initialModels: discoveredModels ?? undefined } : {}), mode: targetPtyId ? 'live' : 'draft', reportedValues, dispatchCommand, @@ -105,6 +111,7 @@ export function useNativeChatSessionOptions(args: { return } let cancelled = false + reportedScreenRef.current = null const reportCurrentValues = async (): Promise => { let authoritativeScreen: string | null = null if (targetPtyId && window.api?.pty?.getMainBufferSnapshot) { @@ -119,18 +126,33 @@ export function useNativeChatSessionOptions(args: { // The mounted renderer buffer remains a transport-neutral fallback. } } - const reportedValues = - readClaudeSessionOptionsFromTerminalScreen(authoritativeScreen) ?? - readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) - if (!cancelled && reportedValues) { + const models = discoveryContext + ? readNativeChatEnrichedModels(agent, discoveryContext.hostKey) + : null + for (const screen of [authoritativeScreen, readTerminalScreen?.() ?? null]) { + const reportedValues = readClaudeSessionOptionsFromTerminalScreen( + screen, + models ?? undefined + ) + if (!reportedValues) { + continue + } + // Why: discovery can land after this read. Keeping the screen that + // parsed lets it re-resolve against the host's real ids later, when the + // frame itself may have already scrolled out of the buffer. + if (cancelled) { + return + } + reportedScreenRef.current = screen surface.reportSessionOptions(reportedValues) + return } } void reportCurrentValues() return () => { cancelled = true } - }, [agent, readTerminalScreen, surface, targetPtyId]) + }, [agent, discoveryContext, readTerminalScreen, surface, targetPtyId]) useEffect(() => { if (!surface || !discoveryContext) { @@ -139,7 +161,16 @@ export function useNativeChatSessionOptions(args: { const unsubscribe = subscribeNativeChatEnrichedModels( agent, discoveryContext.hostKey, - (models) => surface.replaceModels(models) + (models) => { + surface.replaceModels(models) + const screen = agent === 'claude' ? reportedScreenRef.current : null + const reportedValues = screen + ? readClaudeSessionOptionsFromTerminalScreen(screen, models) + : null + if (reportedValues) { + surface.reportSessionOptions(reportedValues) + } + } ) ensureNativeChatModelEnrichment({ agent, diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index b38f2b1c44a..be15b7ee45c 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -9,11 +9,33 @@ import { } from './quick-open-search' describe('quick-open-search', () => { - it('returns the first 50 paths with score 0 for an empty query', () => { - const files = Array.from({ length: 75 }, (_, index) => `src/file-${index}.ts`) + it('orders numbered paths naturally for empty queries and fuzzy-score ties', () => { + const files = prepareQuickOpenFiles([ + 'songs/100 - b.txt', + 'songs/9 - c.txt', + 'songs/99 - a.txt' + ]) + + expect(rankQuickOpenFiles('', files).map((item) => item.path)).toEqual([ + 'songs/9 - c.txt', + 'songs/99 - a.txt', + 'songs/100 - b.txt' + ]) + expect(rankQuickOpenFiles('songs', files).map((item) => item.path)).toEqual([ + 'songs/9 - c.txt', + 'songs/99 - a.txt', + 'songs/100 - b.txt' + ]) + }) + + it('returns the first 50 naturally sorted paths with score 0 for an empty query', () => { + const files = Array.from({ length: 75 }, (_, index) => `src/file-${74 - index}.ts`) expect(rankQuickOpenFiles('', prepareQuickOpenFiles(files))).toEqual( - files.slice(0, QUICK_OPEN_RESULT_LIMIT).map((path) => ({ path, score: 0 })) + Array.from({ length: QUICK_OPEN_RESULT_LIMIT }, (_, index) => ({ + path: `src/file-${index}.ts`, + score: 0 + })) ) }) @@ -35,8 +57,8 @@ describe('quick-open-search', () => { ).toEqual(['src/components/Button.tsx', 'button-area/deep/path/file.tsx']) }) - it('keeps first-seen order for tie-heavy results at the limit boundary', () => { - const files = Array.from({ length: 10 }, (_, index) => `src/path-${index}.bin`) + it('uses natural order for tie-heavy results at the limit boundary', () => { + const files = Array.from({ length: 10 }, (_, index) => `src/path-${9 - index}.bin`) expect(rankQuickOpenFiles('s', prepareQuickOpenFiles(files), 4)).toEqual([ { path: 'src/path-0.bin', score: 0 }, @@ -85,9 +107,9 @@ describe('quick-open-search', () => { it('indexes normalized relative paths without changing path semantics', () => { const files = [ 'src/renderer/src/components/QuickOpen.tsx', + 'legacy\\provider\\raw-path.ts', 'packages/windows-origin/src/App.tsx', - 'single-file.ts', - 'legacy\\provider\\raw-path.ts' + 'single-file.ts' ] expect(prepareQuickOpenFiles(files)).toEqual([ @@ -97,22 +119,22 @@ describe('quick-open-search', () => { lowerFilename: 'quickopen.tsx', inputIndex: 0 }, + { + path: 'legacy\\provider\\raw-path.ts', + lowerPath: 'legacy/provider/raw-path.ts', + lowerFilename: 'raw-path.ts', + inputIndex: 1 + }, { path: 'packages/windows-origin/src/App.tsx', lowerPath: 'packages/windows-origin/src/app.tsx', lowerFilename: 'app.tsx', - inputIndex: 1 + inputIndex: 2 }, { path: 'single-file.ts', lowerPath: 'single-file.ts', lowerFilename: 'single-file.ts', - inputIndex: 2 - }, - { - path: 'legacy\\provider\\raw-path.ts', - lowerPath: 'legacy/provider/raw-path.ts', - lowerFilename: 'raw-path.ts', inputIndex: 3 } ]) diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index 5413c151193..db13042c8fa 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -1,4 +1,5 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import { compareFileNames } from '../../../shared/file-name-sort' export const QUICK_OPEN_RESULT_LIMIT = 50 export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024 @@ -51,7 +52,11 @@ export function rankQuickOpenFiles( // still naturally type backslashes in path queries. const normalizedQuery = query.trim().replace(/\\/g, '/').toLowerCase() if (!normalizedQuery) { - return files.slice(0, limit).map((file) => ({ path: file.path, score: 0 })) + const results: QuickOpenRankedResult[] = [] + for (const file of files) { + retainTopResult(results, { path: file.path, score: 0, inputIndex: file.inputIndex }, limit) + } + return finalizeResults(results) } const results: QuickOpenRankedResult[] = [] @@ -61,10 +66,10 @@ export function rankQuickOpenFiles( continue } - insertTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit) + retainTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit) } - return results.map(({ path, score }) => ({ path, score })) + return finalizeResults(results) } function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number { @@ -106,42 +111,63 @@ type QuickOpenRankedResult = QuickOpenSearchResult & { inputIndex: number } -function insertTopResult( - results: QuickOpenRankedResult[], +function retainTopResult( + heap: QuickOpenRankedResult[], candidate: QuickOpenRankedResult, limit: number ): void { - const worst = results.at(-1) - if (results.length === limit && worst && compareRankedResult(candidate, worst) >= 0) { + if (heap.length === limit && compareRankedResult(candidate, heap[0]) >= 0) { return } - const insertAt = findInsertionIndex(results, candidate) - results.splice(insertAt, 0, candidate) - if (results.length > limit) { - results.pop() + if (heap.length < limit) { + heap.push(candidate) + siftResultUp(heap, heap.length - 1) + return + } + + heap[0] = candidate + siftResultDown(heap) +} + +function siftResultUp(heap: QuickOpenRankedResult[], startIndex: number): void { + let index = startIndex + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2) + if (compareRankedResult(heap[index], heap[parentIndex]) <= 0) { + return + } + ;[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]] + index = parentIndex } } -function findInsertionIndex( - results: readonly QuickOpenRankedResult[], - candidate: QuickOpenRankedResult -): number { - let low = 0 - let high = results.length - - while (low < high) { - const mid = Math.floor((low + high) / 2) - if (compareRankedResult(candidate, results[mid]) < 0) { - high = mid - } else { - low = mid + 1 +function siftResultDown(heap: QuickOpenRankedResult[]): void { + let index = 0 + while (true) { + const leftIndex = index * 2 + 1 + if (leftIndex >= heap.length) { + return } + const rightIndex = leftIndex + 1 + const worseChildIndex = + rightIndex < heap.length && compareRankedResult(heap[rightIndex], heap[leftIndex]) > 0 + ? rightIndex + : leftIndex + if (compareRankedResult(heap[worseChildIndex], heap[index]) <= 0) { + return + } + ;[heap[index], heap[worseChildIndex]] = [heap[worseChildIndex], heap[index]] + index = worseChildIndex } +} - return low +function finalizeResults(results: QuickOpenRankedResult[]): QuickOpenSearchResult[] { + return results + .sort(compareRankedResult) + .map(({ path, score }): QuickOpenSearchResult => ({ path, score })) } function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number { - return a.score - b.score || a.inputIndex - b.inputIndex + return a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex } diff --git a/src/renderer/src/components/repo/repo-icon.test.tsx b/src/renderer/src/components/repo/repo-icon.test.tsx new file mode 100644 index 00000000000..c6ac3a8b355 --- /dev/null +++ b/src/renderer/src/components/repo/repo-icon.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { RepoIconGlyph } from './repo-icon' + +afterEach(() => { + cleanup() +}) + +const GHE_AVATAR = 'https://ghe.example.com/acme.png?size=64' + +describe('RepoIconGlyph', () => { + it('falls back to a lucide icon when an image icon fails to load', () => { + const { container } = render( + + ) + + const image = container.querySelector('img') + expect(image).not.toBeNull() + + fireEvent.error(image as HTMLImageElement) + + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')?.getAttribute('class')).toContain('lucide-folder') + }) + + it('retries the image when the icon changes to a different src', () => { + const { container, rerender } = render( + + ) + fireEvent.error(container.querySelector('img') as HTMLImageElement) + expect(container.querySelector('img')).toBeNull() + + rerender( + + ) + + expect(container.querySelector('img')?.getAttribute('src')).toBe( + 'https://github.com/acme.png?size=64' + ) + }) + + it('keeps rendering a loadable image icon', () => { + const { container } = render( + + ) + + expect(container.querySelector('img')?.getAttribute('src')).toBe(GHE_AVATAR) + expect(container.querySelector('svg')).toBeNull() + }) + + it('renders emoji and lucide icons unchanged', () => { + const { container: emoji } = render() + expect(emoji.textContent).toBe('🐙') + + const { container: lucide } = render( + + ) + expect(lucide.querySelector('svg')?.getAttribute('class')).toContain('lucide-database') + }) + + it('falls back to Folder for an unknown lucide name', () => { + const { container } = render() + + expect(container.querySelector('svg')?.getAttribute('class')).toContain('lucide-folder') + }) +}) diff --git a/src/renderer/src/components/repo/repo-icon.tsx b/src/renderer/src/components/repo/repo-icon.tsx index b82de9e1173..d109200d49d 100644 --- a/src/renderer/src/components/repo/repo-icon.tsx +++ b/src/renderer/src/components/repo/repo-icon.tsx @@ -141,14 +141,20 @@ export function RepoIconGlyph({ iconClassName?: string color?: string }): React.JSX.Element { - if (repoIcon?.type === 'image') { + const imageSrc = repoIcon?.type === 'image' ? repoIcon.src : null + // Why: private-mode GHES avatars need a session cookie, so the load fails and would + // render blank — track the failed src so a different icon still renders. + const [failedImageSrc, setFailedImageSrc] = React.useState(null) + + if (imageSrc !== null && failedImageSrc !== imageSrc) { return ( setFailedImageSrc(imageSrc)} /> ) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index e048925c734..e5a18bc3014 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -49,6 +49,7 @@ import { import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog' import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners' +import { useAiVaultSessionDeleteAction } from './ai-vault-session-delete-action' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -304,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element { }) }, []) + const requestDelete = useAiVaultSessionDeleteAction({ refresh }) + return (
void requestDelete(session)} /> {launchActions.continuationRequest && ( void onRevealLog?: () => void onOpenCwd?: () => void + // Null when Delete is offered; otherwise the tooltip explaining why it isn't. + deleteBlockedReason: string | null + onDelete: () => void }) { const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + const deleteLabel = translate('auto.components.right.sidebar.AiVaultSessionRow.delete', 'Delete') + const deleteItem = ( + + + {deleteLabel} + + ) return ( <> @@ -126,6 +147,25 @@ export function SessionActionMenuItems({ {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} + + {deleteBlockedReason ? ( + + + {/* A disabled item is pointer-events:none, so the trigger needs this + wrapper to receive hover (as WorktreeContextMenu does). */} +
{deleteItem}
+
+ + {deleteBlockedReason} + +
+ ) : ( + deleteItem + )} ) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx new file mode 100644 index 00000000000..ac3e3926d80 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { VaultSessionRow } from './AiVaultSessionRow' + +const session = { + id: 'local:gemini:sess-1:/home/a/.gemini/s.json', + executionHostId: 'local', + agent: 'gemini', + sessionId: 'sess-1', + title: 'A session', + cwd: null, + branch: null, + model: null, + filePath: '/home/a/.gemini/s.json', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: 0, + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'gemini --resume sess-1', + subagent: null +} as unknown as AiVaultSession + +function renderRow(handlers: { onToggleDetails: () => void; onRequestDelete?: () => void }) { + return render( + + + + ) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('VaultSessionRow details toggle', () => { + it('does not expand the row when a menu action is chosen', async () => { + // Radix portals the menu out of the row's DOM, but React bubbles its + // clicks back through the component tree. Expanding here would leave the + // row open behind the confirm dialog, and still open after cancelling. + const onToggleDetails = vi.fn() + const onRequestDelete = vi.fn() + renderRow({ onToggleDetails, onRequestDelete }) + const user = userEvent.setup() + + await user.click(screen.getByTestId('ai-vault-session-more-actions')) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onRequestDelete).toHaveBeenCalledTimes(1) + expect(onToggleDetails).not.toHaveBeenCalled() + }) + + it('still expands when the row itself is clicked', async () => { + const onToggleDetails = vi.fn() + const { container } = renderRow({ onToggleDetails }) + const user = userEvent.setup() + + // The session title: inside the row body, so its click reaches the row's + // own handler — the path a user takes to expand a row. Queried first-match + // because Radix's asChild trigger repeats the subtree. + const title = container.querySelector('[title="Drag to resume in a new tab"]') + expect(title).not.toBeNull() + await user.click(title as Element) + + expect(onToggleDetails).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 416fb2bb79e..9e2f1702f7c 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -14,6 +14,7 @@ import { SessionInlineDetails } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' +import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletability' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { shouldShowAiVaultSessionWorktreeLine, @@ -51,7 +52,8 @@ export function VaultSessionRow({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { session: AiVaultSession liveState: AgentStatusState | null @@ -77,10 +79,14 @@ export function VaultSessionRow({ onOpenLog?: () => void onRevealLog?: () => void onOpenCwd?: () => void + onRequestDelete: (session: AiVaultSession) => void }) { const updatedAt = session.updatedAt ?? session.modifiedAt const detailsId = getSessionDetailsId(session.id) const latestTurn = latestSessionConversationTurn(session) + // Computed once so the dropdown menu and the context menu never disagree. + const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session, liveState) + const requestDelete = (): void => onRequestDelete(session) const detailsTooltip = detailsExpanded ? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details') : translate('auto.components.right.sidebar.AiVaultSessionRow.showDetails', 'Show Details') @@ -120,7 +126,14 @@ export function VaultSessionRow({ 'group/session-row flex w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55', !detailsExpanded && 'min-h-[98px]' )} - onClick={() => { + onClick={(event) => { + // Radix portals this row's menus out of its DOM, but React still + // bubbles their clicks here — without this, choosing Delete expands + // the row behind the dialog. + const target = event.target + if (target instanceof Node && !event.currentTarget.contains(target)) { + return + } onToggleDetails() }} > @@ -169,6 +182,8 @@ export function VaultSessionRow({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onRequestDelete={requestDelete} />
{detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? ( @@ -233,6 +248,8 @@ export function VaultSessionRow({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onDelete={requestDelete} /> diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 2139b60b01d..b56311e519b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -66,7 +66,8 @@ export function AiVaultSessionVirtualList({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { groups: readonly AiVaultSessionGroup[] collapsedGroups: ReadonlySet @@ -93,6 +94,7 @@ export function AiVaultSessionVirtualList({ onOpenLog: (session: AiVaultSession) => void onRevealLog: (session: AiVaultSession) => void onOpenCwd: (session: AiVaultSession) => void + onRequestDelete: (session: AiVaultSession) => void }): React.JSX.Element { const listScrollRef = useRef(null) const stickyRangeStartIndexRef = useRef(0) @@ -229,6 +231,7 @@ export function AiVaultSessionVirtualList({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + onRequestDelete={onRequestDelete} /> ))}
@@ -263,7 +266,8 @@ function AiVaultVirtualRow({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { row: AiVaultListRow | undefined index: number @@ -291,6 +295,7 @@ function AiVaultVirtualRow({ onOpenLog: (session: AiVaultSession) => void onRevealLog: (session: AiVaultSession) => void onOpenCwd: (session: AiVaultSession) => void + onRequestDelete: (session: AiVaultSession) => void }): React.JSX.Element | null { if (!row) { return null @@ -401,6 +406,7 @@ function AiVaultVirtualRow({ onOpenCwd={ canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined } + onRequestDelete={onRequestDelete} /> )}
diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 1688b0721f3..37fce9ae624 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -293,6 +293,14 @@ function makeToolbar(overrides: Partial[0 }) } +function setDownloadPlatform(platform: NodeJS.Platform): void { + ;( + window as unknown as { + api: { platform: { get: () => { platform: NodeJS.Platform } } } + } + ).api.platform = { get: () => ({ platform }) } +} + beforeEach(() => { toastErrorMock.mockReset() toastSuccessMock.mockReset() @@ -677,7 +685,10 @@ describe('FileExplorerRow collapse folder action', () => { it('calls the preload download API and shows success only when not canceled', async () => { const downloadFile = vi .fn() - .mockResolvedValueOnce({ canceled: false, destinationPath: '/downloads/index.ts' }) + .mockResolvedValueOnce({ + canceled: false, + destinationPath: '/downloads/renamed\\entry.ts' + }) .mockResolvedValueOnce({ canceled: true }) const openPath = vi.fn().mockResolvedValue(undefined) ;( @@ -690,6 +701,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { fs: { downloadFile }, shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(fileNode, 'ssh-1') await downloadRemoteFile(fileNode, 'ssh-1') @@ -699,7 +711,7 @@ describe('FileExplorerRow collapse folder action', () => { connectionId: 'ssh-1' }) expect(toastSuccessMock).toHaveBeenCalledTimes(1) - expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'index.ts'", { + expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'renamed\\entry.ts'", { action: { label: 'Open', onClick: expect.any(Function) @@ -709,10 +721,30 @@ describe('FileExplorerRow collapse folder action', () => { | { onClick: () => void } | undefined action?.onClick() - expect(openPath).toHaveBeenCalledWith('/downloads/index.ts') + expect(openPath).toHaveBeenCalledWith('/downloads/renamed\\entry.ts') expect(toastErrorMock).not.toHaveBeenCalled() }) + it('reports the saved folder name from a Windows destination path', async () => { + const downloadFolder = vi.fn().mockResolvedValue({ + canceled: false, + destinationPath: 'C:\\Users\\dev\\Downloads\\src-copy' + }) + ;( + globalThis as unknown as { + window: { api: { fs: { downloadFolder: typeof downloadFolder } } } + } + ).window = { api: { fs: { downloadFolder } } } + setDownloadPlatform('win32') + + await downloadRemoteFile(directoryNode, 'ssh-1') + + expect(toastSuccessMock).toHaveBeenCalledWith( + "Downloaded folder 'src-copy'", + expect.objectContaining({ action: expect.anything() }) + ) + }) + it('calls the preload folder download API for SSH directory rows', async () => { const downloadFolder = vi.fn().mockResolvedValue({ canceled: false, @@ -729,6 +761,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { fs: { downloadFolder }, shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(directoryNode, 'ssh-1') @@ -765,6 +798,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(fileNode, runtimeContext) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 1b621454a95..6167f08d5c6 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -342,6 +342,14 @@ export function shouldShowCopyFileAction( ) } +function getLocalDownloadName(destinationPath: string, platform: NodeJS.Platform): string { + const lastSeparatorIndex = + platform === 'win32' + ? Math.max(destinationPath.lastIndexOf('/'), destinationPath.lastIndexOf('\\')) + : destinationPath.lastIndexOf('/') + return destinationPath.slice(lastSeparatorIndex + 1) +} + export async function downloadRemoteFile( node: TreeNode, connectionIdOrRuntimeContext: string | RuntimeFileOperationArgs @@ -363,17 +371,22 @@ export async function downloadRemoteFile( if (result.canceled) { return } + // Why: POSIX permits backslashes in saved names; only Windows treats them as separators. + const savedName = getLocalDownloadName( + result.destinationPath, + window.api.platform.get().platform + ) toast.success( node.isDirectory ? translate( 'auto.components.right.sidebar.FileExplorerRow.a4029c996b', "Downloaded folder '{{value0}}'", - { value0: node.name } + { value0: savedName } ) : translate( 'auto.components.right.sidebar.FileExplorerRow.bce4d4e44f', "Downloaded '{{value0}}'", - { value0: node.name } + { value0: savedName } ), { action: { diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx index c013c0b0422..9e48965099b 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx @@ -23,6 +23,8 @@ function renderActions(onContinueInNewSession?: () => void): string { onContinueInNewSession={onContinueInNewSession} onCopyId={vi.fn()} onCopyPath={vi.fn()} + deleteBlockedReason={null} + onRequestDelete={vi.fn()} /> ) diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 4813930c290..c41c89ef966 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -58,7 +58,9 @@ export function SessionRowTrailingActions({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + deleteBlockedReason, + onRequestDelete }: { session: AiVaultSession detailsExpanded: boolean @@ -79,6 +81,9 @@ export function SessionRowTrailingActions({ onOpenLog?: () => void onRevealLog?: () => void onOpenCwd?: () => void + // Null when Delete is offered; otherwise the tooltip explaining why it isn't. + deleteBlockedReason: string | null + onRequestDelete: () => void }) { const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) @@ -260,6 +265,8 @@ export function SessionRowTrailingActions({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onDelete={onRequestDelete} /> diff --git a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx new file mode 100644 index 00000000000..9f2529973dc --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx @@ -0,0 +1,363 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { GitBranchCompareSummary } from '../../../../shared/types' +import { + clearBranchLineTotalRequestGateForTests, + getBranchLineTotalMergeBase +} from './branch-line-total-request-gate' +import SourceControl from './SourceControl' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +const mocks = vi.hoisted(() => { + const activeRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' as string + } + const activeWorktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt', + head: 'head-1', + branch: 'refs/heads/feature/line-total', + isBare: false, + isMainWorktree: false, + displayName: 'feature/line-total', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + return { + activeRepo, + activeWorktree, + state: {} as Record + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector?: (state: Record) => unknown) => + selector ? selector(mocks.state) : mocks.state, + { + getState: () => mocks.state + } + ) + return { useAppStore } +}) + +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => mocks.activeWorktree, + useRepoById: (repoId: string | null) => + repoId === mocks.activeRepo.id ? mocks.activeRepo : null, + useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]]) +})) + +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => vi.fn().mockResolvedValue(true) +})) + +vi.mock('./git-status-refresh', () => ({ + refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined), + refreshGitStatusForWorktreeStrict: vi.fn().mockResolvedValue(undefined) +})) + +function noopAsync(value: unknown = undefined): () => Promise { + return vi.fn().mockResolvedValue(value) +} + +const readySummary: GitBranchCompareSummary = { + baseRef: 'refs/remotes/origin/main', + baseOid: 'base-oid', + compareRef: 'feature/line-total', + headOid: 'head-1', + mergeBase: MERGE_BASE, + changedFiles: 2, + commitsAhead: 1, + status: 'ready' +} + +function resetState(overrides: Partial> = {}): void { + vi.clearAllMocks() + mocks.activeRepo.kind = 'git' + mocks.state = { + activeWorktreeId: mocks.activeWorktree.id, + activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' }, + groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] }, + repos: [mocks.activeRepo], + worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] }, + rightSidebarOpen: true, + rightSidebarTab: 'source-control', + gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: readySummary }, + gitBranchLineTotalByWorktree: {}, + gitConflictOperationByWorktree: {}, + remoteStatusesByWorktree: {}, + isRemoteOperationActive: false, + inFlightRemoteOpKind: null, + settings: null, + hostedReviewCache: {}, + prCache: {}, + commitMessageGenerationRecords: {}, + pullRequestGenerationRecords: {}, + openFiles: [], + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + getDiffComments: vi.fn(() => []), + updateSettings: noopAsync(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), + fetchHostedReviewForBranch: noopAsync(), + getHostedReviewCreationEligibility: noopAsync(null), + createHostedReview: noopAsync({ ok: false, error: 'not available' }), + updateWorktreeMeta: noopAsync(), + fetchPRForBranch: noopAsync(), + enqueueGitHubPRRefresh: vi.fn(), + updateRepo: noopAsync(), + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + beginGitBranchCompareRequest: vi.fn(() => 'request-key'), + setGitBranchCompareResult: vi.fn(), + clearGitBranchCompare: vi.fn(), + fetchUpstreamStatus: noopAsync(), + setUpstreamStatus: vi.fn(), + pushBranch: noopAsync(), + pullBranch: noopAsync(), + fastForwardBranch: noopAsync(), + syncBranch: noopAsync(), + rebaseFromBase: noopAsync(), + fetchBranch: noopAsync(), + revealInExplorer: vi.fn(), + trackConflictPath: vi.fn(), + openDiff: vi.fn(), + openFile: vi.fn(), + setEditorViewMode: vi.fn(), + setMarkdownViewMode: vi.fn(), + setPendingEditorReveal: vi.fn(), + openConflictFile: vi.fn(), + openConflictReview: vi.fn(), + openBranchDiff: vi.fn(), + createEmptySplitGroup: vi.fn(() => 'group-2'), + openAllDiffs: vi.fn(), + openBranchAllDiffs: vi.fn(), + openCommitAllDiffs: vi.fn(), + deleteDiffComment: noopAsync(true), + clearDiffComments: noopAsync(true), + clearDiffCommentsForFile: noopAsync(true), + setScrollToDiffCommentId: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'), + setCommitMessageGenerationRecord: vi.fn(), + updateCommitMessageGenerationRecord: vi.fn(), + pruneCommitMessageGenerationRecords: vi.fn(), + allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'), + setPullRequestGenerationRecord: vi.fn(), + updatePullRequestGenerationRecord: vi.fn(), + prunePullRequestGenerationRecords: vi.fn(), + ...overrides + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + clearBranchLineTotalRequestGateForTests() + resetState() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + clearBranchLineTotalRequestGateForTests() +}) + +function renderSourceControl(): void { + act(() => { + root.render( + + + + ) + }) +} + +function chip(): HTMLElement | null { + return container.querySelector('[data-testid="source-control-branch-line-total"]') +} + +function loadingChip(): HTMLElement | null { + return container.querySelector( + '[data-testid="source-control-branch-line-total-loading"]' + ) +} + +describe('SourceControl branch line total request gate', () => { + it('asks for a total when the panel is visible and compare is ready', () => { + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE) + }) + + it('asks for nothing while the sidebar is closed', () => { + resetState({ rightSidebarOpen: false }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing while another right-sidebar tab is showing', () => { + resetState({ rightSidebarTab: 'checks' }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing until branch compare is ready', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'loading' } + } + }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing when compare has no merge base', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'invalid-base' } + } + }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing in a folder workspace', () => { + resetState() + mocks.activeRepo.kind = 'folder' + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('releases the gate when the panel unmounts', () => { + renderSourceControl() + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE) + + act(() => root.render({null})) + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) +}) + +describe('SourceControl branch line total chip', () => { + it('renders a total measured against the current fork point', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()?.getAttribute('aria-label')).toBe('8259 lines added, 670 lines deleted') + // Grouping is pinned to the app locale (`en`), not the runner's host locale. + expect(chip()?.textContent).toBe('+8,259-670') + }) + + it('drops a total whose fork point has since moved', () => { + // Why: status and branch compare refresh on different cadences, so a total + // can outlive the merge base it measured. Stale digits must not render. + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() + }) + + it('drops a published total while branch compare has no ready summary', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'loading', mergeBase: '' } + }, + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() + }) + + // Why: a pending total and one this host will never send look identical from + // here, so a pulsing placeholder would keep pulsing forever on an old host, + // after a hard failure, or during the ranged-diff cooldown. + it('shows no placeholder while the total is still pending', () => { + vi.useFakeTimers() + try { + renderSourceControl() + + expect(chip()).toBeNull() + act(() => { + vi.advanceTimersByTime(20_000) + }) + expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() + expect(container.innerHTML).not.toContain('animate-pulse') + } finally { + vi.useRealTimers() + } + }) + + it('renders nothing for an exact zero total', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 0, removed: 0, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() + }) + + it('omits the zero half of a one-sided total', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 42, removed: 0, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()?.textContent).toBe('+42') + expect(chip()?.getAttribute('aria-label')).toBe('42 lines added') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx index 8624d25fae2..d3125ee86c7 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx @@ -104,6 +104,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index 22bc4954b20..13dc2479894 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -156,6 +156,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 68b55f9bda7..d70ad7dc4d8 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -124,8 +124,8 @@ import { buildActiveOpenRowKeys } from './source-control-active-open-file-keys' import { + filterAndSortSourceControlPathEntries, filterSourceControlGroupedPathEntries, - filterSourceControlPathEntries, getSourceControlFileFilterState } from './source-control-file-filter' import { getCommitMessageTextareaRows } from './source-control-commit-message-rows' @@ -296,6 +296,7 @@ import { } from './source-control-hosted-review-push-target' import { buildSourceControlManualReviewUrlFromContext } from './source-control-manual-review-url' import { parseRemoteRepo } from './source-control-remote-repo' +import { setBranchLineTotalMergeBase } from './branch-line-total-request-gate' export { HostedReviewHeaderLink } from './hosted-review-header-chrome' import { createRunningCommitMessageGenerationRecord, @@ -835,6 +836,15 @@ function SourceControlInner(): React.JSX.Element { const branchSummary = useAppStore((s) => activeWorktreeId ? (s.gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null) : null ) + const publishedBranchLineTotal = useAppStore((s) => + activeWorktreeId ? (s.gitBranchLineTotalByWorktree?.[activeWorktreeId] ?? null) : null + ) + // Why: status and branch compare refresh on different cadences, so a total can + // outlive the fork point it measured. Drop it rather than render a stale number. + const branchLineTotal = + publishedBranchLineTotal && publishedBranchLineTotal.mergeBase === branchSummary?.mergeBase + ? publishedBranchLineTotal + : null const conflictOperation = useAppStore((s) => activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' ) @@ -1252,6 +1262,22 @@ function SourceControlInner(): React.JSX.Element { // Why: the sidebar stays mounted when closed, so gate polling on tab AND open or branchCompare/PR fetch would run with no visible consumer. const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen + // Why: the merge base IS the request gate — no OID on the status request means + // the host runs no ranged diff, so a hidden chip costs a background worktree nothing. + const requestedBranchLineTotalMergeBase = + isBranchVisible && !isFolder && branchSummary?.status === 'ready' + ? branchSummary.mergeBase + : null + useEffect(() => { + if (!activeWorktreeId) { + return + } + setBranchLineTotalMergeBase(activeWorktreeId, requestedBranchLineTotalMergeBase) + return () => { + setBranchLineTotalMergeBase(activeWorktreeId, null) + } + }, [activeWorktreeId, requestedBranchLineTotalMergeBase]) + const refreshActiveGitStatus = useCallback( async (signal?: AbortSignal): Promise => { if (!activeWorktreeId || !worktreePath || isFolder) { @@ -1784,7 +1810,7 @@ function SourceControlInner(): React.JSX.Element { ) const filteredBranchEntries = useMemo( - () => filterSourceControlPathEntries(branchEntries, fileFilterState), + () => filterAndSortSourceControlPathEntries(branchEntries, fileFilterState), [branchEntries, fileFilterState] ) @@ -5549,6 +5575,7 @@ function SourceControlInner(): React.JSX.Element { diffCommentCount={diffCommentCount} onExpandNotes={() => setDiffCommentsExpanded(true)} branchSummary={branchSummary} + branchLineTotal={branchLineTotal} compareBaseRef={compareBaseRef} headDisplay={gitIdentityDisplay} upstreamStatus={remoteStatus} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx index 7e1d4c7d2ec..5ff61b17d43 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx @@ -116,6 +116,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts new file mode 100644 index 00000000000..49e8d39e123 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletability' + +// translate() with no loaded catalog returns the English fallback, so these +// assertions pin the English copy as well as the gate order. +const NON_LOCAL = 'Only sessions on this device can be deleted.' +const SYNTHETIC = "This session can't be deleted from Orca." +const LIVE = 'This session is still running — wait for it to finish before deleting.' + +const localGeminiSession = { + agent: 'gemini' as const, + executionHostId: 'local' as const, + filePath: '/home/user/.gemini/sessions/log.jsonl' +} + +describe('aiVaultSessionDeleteBlockedReason', () => { + it('offers Delete for a deletable agent on a local, real path', () => { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession)).toBeNull() + }) + + it('offers Delete for a directory-shaped agent (claude)', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'claude', + executionHostId: 'local', + filePath: '/home/user/.claude/projects/-proj/sess-1.jsonl' + }) + ).toBeNull() + }) + + it('tells the user to wait while the agent is still running', () => { + for (const live of ['working', 'blocked', 'waiting'] as const) { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, live)).toBe(LIVE) + } + }) + + it('offers Delete for a finished session (done) and one with no live state', () => { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, 'done')).toBeNull() + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, null)).toBeNull() + }) + + it('keeps the permanent reason over "running" for an unsupported live session', () => { + // A live but unsupported agent stays "unsupported" — it would never become + // deletable, so "wait for it to finish" would mislead. + expect( + aiVaultSessionDeleteBlockedReason( + { agent: 'codex', executionHostId: 'local', filePath: '/home/user/.codex/x.jsonl' }, + 'working' + ) + ).toBe("Codex sessions can't be deleted from Orca.") + }) + + it('blocks ssh- and runtime-hosted sessions regardless of agent', () => { + for (const executionHostId of ['ssh:dev-box', 'runtime:gpu-box'] as const) { + expect(aiVaultSessionDeleteBlockedReason({ ...localGeminiSession, executionHostId })).toBe( + NON_LOCAL + ) + } + }) + + it('blocks a synthetic OpenCode SQLite row identity', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'opencode', + executionHostId: 'local', + filePath: '/home/user/.opencode/db.sqlite#sess_123' + }) + ).toBe(SYNTHETIC) + }) + + it('names the agent without explaining why it is unsupported', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'opencode', + executionHostId: 'local', + filePath: '/home/user/.opencode/sessions/log.jsonl' + }) + ).toBe("OpenCode sessions can't be deleted from Orca.") + }) + + it('gives a multi-cause agent (antigravity) the same single sentence', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'antigravity', + executionHostId: 'local', + filePath: '/home/user/.antigravity/brain/conv-1/.system_generated/logs/transcript.jsonl' + }) + ).toBe("Antigravity sessions can't be deleted from Orca.") + }) + + it('prioritizes the host gate over the unsupported-agent reason', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'claude', + executionHostId: 'ssh:dev-box', + filePath: '/home/user/.claude/sessions/sess-dir/log.jsonl' + }) + ).toBe(NON_LOCAL) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts new file mode 100644 index 00000000000..033ebd87c81 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts @@ -0,0 +1,60 @@ +import { isAiVaultDeletableAgent } from '../../../../shared/ai-vault-session-deletion' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AgentStatusState } from '../../../../shared/agent-status-types' +import { translate } from '@/i18n/i18n' +import { agentLabel } from './ai-vault-session-filters' +import { + canUseLocalAiVaultSessionPathActions, + isSyntheticAiVaultSessionPath +} from './ai-vault-session-path-actions' + +// Matches the active-dot rule in ai-vault-session-row-display. +function isSessionLive(liveState: AgentStatusState | null | undefined): boolean { + return liveState != null && liveState !== 'done' +} + +/** + * Why Delete is unavailable for this session, as the tooltip text to show — or + * null when it is offered. Each message says which sessions are affected, never + * why: a provider's storage layout is Orca's problem, not the reader's. + * + * NOT the security boundary — main re-validates the path on disk regardless. + * The two sides agree on deletable-or-not but deliberately not on the order they + * check, so an SSH session reads as "remote" rather than "unsupported agent". + * What must hold is that renderer-deletable is a subset of main-deletable, and + * it does: both consult the same shared agent set and host/synthetic predicates. + */ +export function aiVaultSessionDeleteBlockedReason( + session: Pick, + liveState?: AgentStatusState | null +): string | null { + if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonNonLocalHost', + 'Only sessions on this device can be deleted.' + ) + } + if (isSyntheticAiVaultSessionPath(session.filePath)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSyntheticPath', + "This session can't be deleted from Orca." + ) + } + if (!isAiVaultDeletableAgent(session.agent)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonUnsupportedAgent', + "{{value0}} sessions can't be deleted from Orca.", + { value0: agentLabel(session.agent) } + ) + } + // Last, so an otherwise-deletable session reads as "wait for it to finish" + // rather than a permanent reason. Trashing a live transcript would drop the + // writes the agent is still appending. + if (isSessionLive(liveState)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSessionLive', + 'This session is still running — wait for it to finish before deleting.' + ) + } + return null +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts new file mode 100644 index 00000000000..629165a1a0c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts @@ -0,0 +1,68 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { agentLabel } from './ai-vault-session-filters' + +/** + * Confirms, then deletes an AI Vault session: toasts the outcome and forces a + * refresh so the row goes away immediately. + */ +export function useAiVaultSessionDeleteAction({ + refresh +}: { + refresh: (options: { force: boolean }) => Promise +}): (session: AiVaultSession) => Promise { + const confirm = useConfirmationDialog() + + return useCallback( + async (session: AiVaultSession) => { + const confirmed = await confirm({ + title: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.title', + 'Delete this session?' + ), + description: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.description', + '"{{value0}}" will be deleted. Once deleted, it will no longer be resumable from {{value1}}\'s own command line either.', + { value0: session.title, value1: agentLabel(session.agent) } + ), + confirmLabel: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.confirm', + 'Delete' + ), + confirmVariant: 'destructive' + }) + if (!confirmed) { + return + } + try { + const result = await window.api.aiVault.deleteSession({ + agent: session.agent, + filePath: session.filePath, + executionHostId: session.executionHostId + }) + if (result.outcome !== 'deleted') { + // 'rejected' and 'failed' share one message: the specific reason is a + // main-side detail, not something to surface raw. + throw new Error(result.outcome) + } + toast.success( + translate('auto.components.right.sidebar.AiVaultPanel.sessionDeleted', 'Session deleted') + ) + // Main already invalidated its caches; this is only for immediate UX. + void refresh({ force: true }) + } catch { + // A rejected IPC invoke (transport/serialization) lands here too. + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.sessionDeleteFailed', + "Couldn't delete the session" + ) + ) + } + }, + [confirm, refresh] + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts index 5bbbdcf2d1d..cdf7d415671 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -4,6 +4,11 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { isAiVaultSyntheticSessionPath } from '../../../../shared/ai-vault-session-deletion' + +// One definition, shared with main's delete validator; re-exported here under +// the name this module's callers already use. +export { isAiVaultSyntheticSessionPath as isSyntheticAiVaultSessionPath } export function canUseLocalAiVaultSessionPathActions( executionHostId: ExecutionHostId | null | undefined @@ -13,14 +18,6 @@ export function canUseLocalAiVaultSessionPathActions( return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID } -export function isSyntheticAiVaultSessionPath(filePath: string): boolean { - // Why: newer OpenCode sessions use a synthetic `#` - // scanner identity backed by SQLite — not a real filesystem path. A '#' - // session marker never appears in a genuine local transcript path, so it is - // a reliable v1 signal that there is no single file to open in Orca. - return filePath.includes('#') -} - /** * Whether AI Vault `View Log` / `Open Log` can open this session's log inside * Orca as a read-only tab: a non-blank, local, single-file (non-synthetic) @@ -37,5 +34,5 @@ export function canOpenAiVaultSessionLogInOrca( if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { return false } - return !isSyntheticAiVaultSessionPath(filePath) + return !isAiVaultSyntheticSessionPath(filePath) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 3af723343fb..b1e9b819ac0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -20,7 +20,7 @@ const EMPTY_RESULT: AiVaultListResult = { scannedAt: '2026-07-01T00:00:00.000Z' } -const THROTTLE_MS = 5_000 +const THROTTLE_MS = 30_000 const listSessionsMock = vi.fn<(args: unknown) => Promise>() const cancelListSessionsMock = vi.fn<() => Promise>() @@ -206,14 +206,14 @@ afterEach(() => { }) describe('useAiVaultSessionRefresh refocus behavior', () => { - it('bypasses the scan cache on mount so panel entry shows new sessions', async () => { + it('uses the shared scan cache on local panel entry', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ executionHostScope: 'local', - force: true + force: false }) }) @@ -351,32 +351,25 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z') }) - it('force re-scans on refocus once the throttle allows it', async () => { + it('refreshes from the shared cache on refocus', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - await advance(THROTTLE_MS + 1) await fireWindowFocused() expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ force: false }) }) - it('defers a refocus inside the throttle window to a trailing forced scan', async () => { + it('does not force transcript scans for refocus events', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - // Within the throttle window nothing runs yet — the event must not be - // dropped, so it lands as one trailing scan when the throttle frees up. await fireWindowFocused() - await dispatch(document, 'visibilitychange') - expect(listSessionsMock).toHaveBeenCalledTimes(1) - - await advance(THROTTLE_MS + 1) expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ force: false }) }) it('ignores focus/visibility events while the document is hidden', async () => { @@ -393,15 +386,12 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(listSessionsMock).toHaveBeenCalledTimes(1) }) - it('stops listening and cancels trailing scans after unmount', async () => { + it('stops listening after unmount', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - // Queue a trailing forced scan, then unmount before it fires. - await fireWindowFocused() roots.splice(0).forEach((root) => act(() => root.unmount())) - await advance(THROTTLE_MS + 1) await fireWindowFocused() await dispatch(document, 'visibilitychange') @@ -479,7 +469,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(lastCallArgs()).toMatchObject({ force: true }) }) - it('counts a manual force refresh against the rescan throttle', async () => { + it('keeps refocus cache-backed after a manual force refresh', async () => { await renderHook() await flushMicrotasks() @@ -489,9 +479,9 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { }) expect(listSessionsMock).toHaveBeenCalledTimes(2) - // The button just scanned; an immediate refocus defers to trailing. await fireWindowFocused() - expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + expect(lastCallArgs()).toMatchObject({ force: false }) }) }) @@ -514,10 +504,33 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => { expect(listSessionsMock).toHaveBeenCalledTimes(1) await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) - expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) await advance(THROTTLE_MS + 1) - expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('re-budgets a trailing agent scan after a manual refresh', async () => { + await renderHook() + await flushMicrotasks() + + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) + await advance(10_000) + await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2') }) + await advance(10_000) + await act(async () => { + await latest?.refresh({ force: true }) + }) + + await advance(10_001) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + + await advance(19_999) + expect(listSessionsMock).toHaveBeenCalledTimes(4) expect(lastCallArgs()).toMatchObject({ force: true }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index e9cafb17d8a..d991a9510e1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -4,7 +4,7 @@ import { type AiVaultListResult, type AiVaultSession } from '../../../../shared/ai-vault-types' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostScope } from '../../../../shared/execution-host' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' import type { AiVaultSessionLimit } from './ai-vault-session-limit' import { @@ -14,22 +14,11 @@ import { resetAiVaultSessionResultCacheForTest } from './ai-vault-session-result-cache' -// Panel entry and window refocus must show sessions started since the last -// scan, so they bypass the main process's 15s cache — but a full scan parses -// up to ~1000 transcripts, so bound forced scans to one per interval. Module -// scope so the throttle survives panel remounts (the panel unmounts per tab). -const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000 +// In-app session creation bypasses the cache so the new session appears promptly. +// Keep the budget at module scope so tab remounts cannot amplify full scans. +const FORCED_RESCAN_MIN_INTERVAL_MS = 30_000 let lastForcedRescanAt = 0 -function consumeForcedRescanBudget(): boolean { - const now = Date.now() - if (now - lastForcedRescanAt < FORCED_RESCAN_MIN_INTERVAL_MS) { - return false - } - lastForcedRescanAt = now - return true -} - export function resetAiVaultForcedRescanThrottleForTest(): void { lastForcedRescanAt = 0 resetAiVaultSessionResultCacheForTest() @@ -201,7 +190,7 @@ export function useAiVaultSessionRefresh( [currentScanScopeKey] ) - // Forced rescans triggered by events (refocus, agent-session starts) run + // Forced rescans triggered by new agent sessions run // immediately when the throttle allows, otherwise once as soon as it frees // up — dropping the event would leave a just-started session invisible // until some unrelated later trigger. @@ -218,8 +207,7 @@ export function useAiVaultSessionRefresh( } forcedRescanTimerRef.current = setTimeout(() => { forcedRescanTimerRef.current = null - lastForcedRescanAt = Date.now() - void refresh({ background: true, force: true }) + requestForcedRescan() }, waitMs) }, [refresh]) @@ -240,33 +228,23 @@ export function useAiVaultSessionRefresh( } }, []) - // Remote scans can take long enough for normal tab navigation to feel stuck, - // so re-entering a remote panel uses its host/scope cache. Explicit refresh, - // app refocus and new in-app agent sessions still force a fresh scan. + // Panel entry reuses the renderer result first, then the host scan cache. useEffect(() => { if (refreshInFlightRef.current) { void window.api.aiVault.cancelListSessions({ requestToken: requestTokenRef.current }) } - const refreshOnEntry = executionHostScope === LOCAL_EXECUTION_HOST_ID - const force = refreshOnEntry && consumeForcedRescanBudget() - void refresh({ force, reuseLoadedDepth: true }) - if (refreshOnEntry && !force) { - requestForcedRescan() - } - }, [executionHostScope, refresh, requestForcedRescan, scanScopeKey]) + void refresh({ force: false, reuseLoadedDepth: true }) + }, [executionHostScope, refresh, scanScopeKey]) - // Sessions started while the app was backgrounded should appear when the - // user returns, so refocus also bypasses the scan cache (throttled). OS - // refocus arrives via the main process — renderer DOM focus events don't - // fire on macOS app activation; visibilitychange covers minimize-restore. + // Refocus checks the shared host cache without forcing another transcript scan. useEffect(() => { const onRefocus = (): void => { if (document.visibilityState !== 'visible') { return } - requestForcedRescan() + void refresh({ background: true, force: false }) } const unsubscribeWindowFocus = window.api.aiVault.onWindowFocused?.(onRefocus) document.addEventListener('visibilitychange', onRefocus) @@ -274,7 +252,7 @@ export function useAiVaultSessionRefresh( unsubscribeWindowFocus?.() document.removeEventListener('visibilitychange', onRefocus) } - }, [requestForcedRescan]) + }, [refresh]) // Sessions started inside Orca never blur the window, so refocus alone // can't surface them. Agent hooks already report provider sessions; re-scan diff --git a/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts new file mode 100644 index 00000000000..600a3cf8fc7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + clearBranchLineTotalRequestGateForTests, + getBranchLineTotalMergeBase, + setBranchLineTotalMergeBase +} from './branch-line-total-request-gate' + +describe('branch line total request gate', () => { + beforeEach(() => { + clearBranchLineTotalRequestGateForTests() + }) + + it('reports no merge base for a worktree that never opened the chip', () => { + expect(getBranchLineTotalMergeBase('wt-never-gated')).toBeUndefined() + }) + + it('stores the merge base a visible chip asked for', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + + expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-1') + }) + + it('replaces the merge base when the fork point moves', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', 'merge-base-2') + + expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-2') + }) + + it('deletes the entry when the chip is hidden', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', null) + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + }) + + it('treats an empty merge base as no gate rather than an empty request param', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', '') + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + }) + + it('keeps entries independent per worktree', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-2', 'merge-base-2') + setBranchLineTotalMergeBase('wt-1', null) + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + expect(getBranchLineTotalMergeBase('wt-2')).toBe('merge-base-2') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts new file mode 100644 index 00000000000..c3fa7b98369 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts @@ -0,0 +1,23 @@ +// Why: several independent code paths call `git.status`; if only some carried +// the merge base the branch line-total chip would blank out between polls. One +// registry keeps every path asking for the same thing. +// Only the visible worktree's SourceControl is mounted and its effect cleanup +// deletes on every change, so this holds ~1 entry and needs no eviction. +const branchLineTotalMergeBaseByWorktree = new Map() + +/** `null` deletes the entry — that absence is the visibility gate (no merge base ⇒ no host cost). */ +export function setBranchLineTotalMergeBase(worktreeId: string, mergeBase: string | null): void { + if (!mergeBase) { + branchLineTotalMergeBaseByWorktree.delete(worktreeId) + return + } + branchLineTotalMergeBaseByWorktree.set(worktreeId, mergeBase) +} + +export function getBranchLineTotalMergeBase(worktreeId: string): string | undefined { + return branchLineTotalMergeBaseByWorktree.get(worktreeId) +} + +export function clearBranchLineTotalRequestGateForTests(): void { + branchLineTotalMergeBaseByWorktree.clear() +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts new file mode 100644 index 00000000000..a0e40f2ef12 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' + +const readRuntimeDirectory = vi.fn() +vi.mock('@/runtime/runtime-file-client', () => ({ + readRuntimeDirectory: (...args: unknown[]) => readRuntimeDirectory(...args) +})) +vi.mock('./file-explorer-operation-owner', () => ({ + getFileExplorerOperationOwner: () => ({ kind: 'local' }), + getFileExplorerOperationRoute: () => ({ settings: null, connectionId: null }), + getFileExplorerOwnerUnresolvedMessage: () => 'unresolved' +})) + +import { readFileExplorerDirectory } from './file-explorer-directory-listing' + +describe('readFileExplorerDirectory', () => { + it('re-sorts backend order — remote-runtime and paired-web routes return the host order verbatim', async () => { + readRuntimeDirectory.mockResolvedValueOnce([ + { name: '100 - b.txt', isDirectory: false, isSymlink: false }, + { name: '9 - c.txt', isDirectory: false, isSymlink: false }, + { name: '10 - dir', isDirectory: true, isSymlink: false }, + { name: '99 - a.txt', isDirectory: false, isSymlink: false } + ]) + + const { entries } = await readFileExplorerDirectory('wt-1', '/w', '/w/dir') + expect(entries.map((e) => e.name)).toEqual([ + '10 - dir', + '9 - c.txt', + '99 - a.txt', + '100 - b.txt' + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts index 13742226c9a..eb8f7142846 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -1,5 +1,6 @@ import { joinPath, normalizeRelativePath } from '@/lib/path' import type { DirEntry } from '../../../../shared/types' +import { sortDirEntries } from '../../../../shared/file-name-sort' import { readRuntimeDirectory } from '@/runtime/runtime-file-client' import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' @@ -56,5 +57,8 @@ export async function readFileExplorerDirectory( }, dirPath ) - return { entries, operationOwner } + // Why: remote-runtime and paired-web routes return the host's order verbatim, + // and an older host may still sort lexicographically; re-sorting an already + // sorted local listing is near-free (adaptive sort). + return { entries: sortDirEntries(entries), operationOwner } } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index ba057328924..9070472ed0c 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -1,5 +1,6 @@ import { joinPath, normalizeRelativePath } from '@/lib/path' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { compareFileNames } from '../../../../shared/file-name-sort' import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' import { createFileExplorerRowProjectionFromParts, @@ -232,7 +233,7 @@ function appendNameFilteredEntries( if (a.node.isDirectory !== b.node.isDirectory) { return a.node.isDirectory ? -1 : 1 } - return a.node.name.localeCompare(b.node.name) + return compareFileNames(a.node.name, b.node.name) }) for (const entry of sortedEntries) { visibleFlatRows.push(entry.node) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts new file mode 100644 index 00000000000..3437d8d227a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearBranchLineTotalRequestGateForTests, + setBranchLineTotalMergeBase +} from './branch-line-total-request-gate' +import { + clearGitStatusRefreshOrderingForTests, + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict, + type GitStatusRefreshDeps +} from './git-status-refresh' +import type { GitStatusResult } from '../../../../shared/types' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +function makeDeps(): GitStatusRefreshDeps { + return { + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + setUpstreamStatus: vi.fn(), + fetchUpstreamStatus: vi.fn().mockResolvedValue(null) + } +} + +function stubGitStatus(): ReturnType { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { + api: { + git: { + status: gitStatus, + cancelStatus: vi.fn().mockResolvedValue(undefined), + upstreamStatus: vi.fn().mockResolvedValue({ hasUpstream: false, ahead: 0, behind: 0 }) + } + } + }) + return gitStatus +} + +describe('branch line total request gate on git status refreshes', () => { + beforeEach(() => { + vi.unstubAllGlobals() + clearGitStatusRefreshOrderingForTests() + clearBranchLineTotalRequestGateForTests() + }) + + it('omits the merge base when no chip is asking for a total', async () => { + const gitStatus = stubGitStatus() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-hidden', + worktreePath: '/repo', + connectionId: 'ssh-1', + deps: makeDeps() + }) + + // Why: no OID on the request is the whole performance contract — the host + // runs no ranged diff, so a background worktree costs nothing. + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + }) + + it('omits the merge base for a worktree whose chip was hidden again', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + setBranchLineTotalMergeBase('wt-1', null) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined + }) + }) + + it('does not send one worktree gate on another worktree status pass', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-visible', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-background', + worktreePath: '/other-repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/other-repo', + connectionId: undefined + }) + }) + + it('sends the merge base on a plain refresh that passes no request options', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + // Why: this path used to build no options object at all, so the gate was + // silently dropped on the most common (fs-watcher) refresh. + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1', + branchLineTotalMergeBase: MERGE_BASE + }) + }) + + it('sends the merge base alongside line-stat reuse', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps(), + request: { reuseLineStats: true } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + reuseLineStats: true, + branchLineTotalMergeBase: MERGE_BASE + }) + }) + + it('sends the merge base on an abortable refresh', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + const controller = new AbortController() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps(), + request: { signal: controller.signal } + }) + + expect(gitStatus).toHaveBeenCalledWith( + expect.objectContaining({ + worktreePath: '/repo', + branchLineTotalMergeBase: MERGE_BASE + }) + ) + }) + + it('re-reads the gate on every pass so a moved fork point is not sent twice', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + setBranchLineTotalMergeBase('wt-1', 'rebased-merge-base') + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: MERGE_BASE + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: 'rebased-merge-base' + }) + }) + + it('omits the merge base from a strict refresh when no chip is visible', async () => { + const gitStatus = stubGitStatus() + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps: { ...makeDeps(), fetchUpstreamStatus: undefined } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + + // Why: a strict refresh is exactly the commit/push/sync that moves the number; + // skipping the gate there would blank the chip until the next automatic poll. + it('sends the merge base on a strict refresh', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-strict', MERGE_BASE) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps: { ...makeDeps(), fetchUpstreamStatus: undefined } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true, + branchLineTotalMergeBase: MERGE_BASE + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 596a68f8df3..50f2cf068c7 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -1,4 +1,5 @@ import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' +import { getBranchLineTotalMergeBase } from './branch-line-total-request-gate' import { clearAutomaticPushTargetUpstreamStatusCache, getCachedAutomaticPushTargetUpstreamStatus, @@ -121,6 +122,9 @@ export async function refreshGitStatusForWorktree({ } }): Promise { const refreshOrder = beginAutomaticUpstreamRefresh(worktreeId) + // Why: every status path must carry the merge base, or the chip blanks out on + // whichever poll happened to omit it. + const branchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) try { const status = (await getRuntimeGitStatus( { @@ -129,12 +133,11 @@ export async function refreshGitStatusForWorktree({ worktreePath, connectionId }, - request - ? { - ...(request.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(request.signal ? { signal: request.signal } : {}) - } - : undefined + { + ...(request?.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(request?.signal ? { signal: request.signal } : {}), + ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) + } )) as GitStatusResult if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { @@ -247,6 +250,7 @@ export async function refreshGitStatusForWorktreeStrict({ }): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> { beginStrictUpstreamRefresh(worktreeId) clearAutomaticPushTargetUpstreamStatusCache() + const strictBranchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) const status = (await getRuntimeGitStatus( { settings, @@ -257,7 +261,12 @@ export async function refreshGitStatusForWorktreeStrict({ { // Why: strict refreshes are user-triggered reconciliation and must not reuse // automatic polling's no-upstream backoff window. - bypassEffectiveUpstreamNegativeCache: true + bypassEffectiveUpstreamNegativeCache: true, + // Why: an absent total clears the chip, so a path that skipped the gate + // would blank it right after the commit or push that moved the number. + ...(strictBranchLineTotalMergeBase + ? { branchLineTotalMergeBase: strictBranchLineTotalMergeBase } + : {}) } )) as GitStatusResult diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 4a454224997..9ccad4f3acc 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { SourceControlBranchContextRow } from './source-control-branch-context-row' import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchLineTotal } from '../../../../shared/git-status-types' vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: ReactNode }) => <>{children}, @@ -10,6 +11,12 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} })) +vi.mock('@/components/ui/hover-card', () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + const readySummary: GitBranchCompareSummary = { baseRef: 'refs/remotes/origin/FRONT-192-ZisVoucherStrip', baseOid: 'base', @@ -215,3 +222,325 @@ describe('SourceControlBranchContextRow', () => { expect(markup).toContain('aria-label="Open review page in browser"') }) }) + +function renderWithLineTotal( + branchLineTotal: GitBranchLineTotal | null | undefined, + summary: GitBranchCompareSummary | null = readySummary +): string { + return renderToStaticMarkup( + + ) +} + +describe('SourceControlBranchContextRow branch line total', () => { + it('renders both halves with grouped digits and a spoken label', () => { + const markup = renderWithLineTotal({ added: 8259, removed: 670, mergeBase: 'base' }) + + expect(markup).toContain('+8,259') + expect(markup).toContain('-670') + // Label reads raw digits; the grouped spans are decoration. + expect(markup).toContain('aria-label="8259 lines added, 670 lines deleted"') + expect(markup).toContain('tabular-nums') + expect(markup).toContain('text-[color:var(--git-decoration-added)]') + expect(markup).toContain('text-[color:var(--git-decoration-deleted)]') + // Not clickable in v1 — the chip's scope differs from openBranchAllDiffs. + expect(markup).not.toContain(' + {id === 'local-network' && ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx index f4f97c1bb64..c58fb017ba4 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx @@ -7,6 +7,16 @@ import type { DeveloperPermissionState } from '../../../../shared/developer-perm import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { DeveloperPermissionsPane } from './DeveloperPermissionsPane' +const toastMessageMock = vi.hoisted(() => vi.fn()) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: toastMessageMock, + success: vi.fn() + } +})) + let container: HTMLDivElement let root: Root @@ -19,10 +29,16 @@ beforeEach(() => { { id: 'full-disk-access', status: 'denied' } ] ), - request: vi.fn() + request: vi.fn(async ({ id }: { id: string }) => ({ + id, + status: 'unknown', + openedSystemSettings: false + })), + openSettings: vi.fn() } } }) + toastMessageMock.mockReset() container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -34,6 +50,50 @@ afterEach(async () => { Reflect.deleteProperty(window, 'api') }) +function setPermissionStates(states: DeveloperPermissionState[]): void { + const api = (window as unknown as { api: { developerPermissions: { getStatus: unknown } } }).api + api.developerPermissions.getStatus = vi.fn(async () => states) +} + +it('requests Local Network access without claiming a permission verdict', async () => { + setPermissionStates([{ id: 'local-network', status: 'unknown' }]) + + await act(async () => { + root.render() + }) + + const row = container.querySelector( + '[data-settings-section="developer-permissions-local-network"]' + ) + const [requestButton, settingsButton] = Array.from( + row?.querySelectorAll('button') ?? [] + ) + expect(row?.textContent).toContain('Managed by macOS') + expect(row?.textContent).toContain("macOS does not report this permission's current status") + expect(requestButton?.textContent).toContain('Request Access') + expect(settingsButton?.textContent).toContain('Open System Settings') + + await act(async () => requestButton?.click()) + + const api = window.api.developerPermissions + expect(api.request).toHaveBeenCalledWith({ id: 'local-network' }) + expect(toastMessageMock).toHaveBeenCalledWith( + 'Check for a macOS prompt', + expect.objectContaining({ + description: + 'If prompted, choose Allow. If no prompt appears, open System Settings and enable Orca under Privacy & Security → Local Network.', + action: expect.objectContaining({ label: 'Open System Settings' }) + }) + ) + + const options = toastMessageMock.mock.calls[0]?.[1] as { action?: { onClick?: () => void } } + options.action?.onClick?.() + expect(api.openSettings).toHaveBeenCalledWith({ id: 'local-network' }) + + await act(async () => settingsButton?.click()) + expect(api.openSettings).toHaveBeenCalledTimes(2) +}) + it('highlights the Full Disk Access row for a targeted Settings navigation', async () => { await act(async () => { root.render( diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 31576f0e1ce..18b48aa918b 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -3,7 +3,6 @@ import { Accessibility, Bluetooth, Camera, - ExternalLink, HardDrive, Mic, MonitorUp, @@ -20,10 +19,14 @@ import type { } from '../../../../shared/developer-permissions-types' import { Button } from '../ui/button' import { translate } from '@/i18n/i18n' +import { DeveloperPermissionActions } from './DeveloperPermissionActions' +import { LocalNetworkConnectionTest } from './LocalNetworkConnectionTest' import { developerPermissionStatusClass, developerPermissionStatusLabel } from './developer-permission-status' +import { showDeveloperPermissionRequestNotice } from './developer-permission-request-notice' +import { TerminalTccAttributionNotice } from './TerminalTccAttributionNotice' export { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search' type DeveloperPermissionsPaneProps = { @@ -156,18 +159,21 @@ const PERMISSIONS: PermissionDefinition[] = [ { id: 'local-network', get label() { - return translate('auto.components.settings.DeveloperPermissionsPane.e7bb06007c', 'LAN') + return translate( + 'auto.components.settings.DeveloperPermissionsPane.e7bb06007c', + 'Local Network' + ) }, get description() { return translate( 'auto.components.settings.DeveloperPermissionsPane.f903bf20b5', - 'Discovery and access for development servers on your network.' + "Allows terminals and development tools to connect to services on your local network. macOS does not report this permission's current status to Orca." ) }, get actionLabel() { return translate( - 'auto.components.settings.DeveloperPermissionsPane.actionTriggerPrompt', - 'Trigger Prompt' + 'auto.components.settings.DeveloperPermissionsPane.actionRequestAccess', + 'Request Access' ) }, icon: @@ -289,28 +295,9 @@ export function DeveloperPermissionsPane({ if (!mountedRef.current) { return } - if (result.status === 'granted') { - toast.success( - translate( - 'auto.components.settings.DeveloperPermissionsPane.48d87edcd2', - 'Permission granted' - ) - ) - } else if (result.openedSystemSettings) { - toast.message( - translate( - 'auto.components.settings.DeveloperPermissionsPane.fa809e8ada', - 'Opened macOS Privacy & Security' - ) - ) - } else { - toast.message( - translate( - 'auto.components.settings.DeveloperPermissionsPane.66e94d6cf3', - 'Permission request sent' - ) - ) - } + showDeveloperPermissionRequestNotice(result, () => { + void window.api.developerPermissions.openSettings({ id: 'local-network' }) + }) } catch { if (mountedRef.current) { toast.error( @@ -329,6 +316,7 @@ export function DeveloperPermissionsPane({ return (
+
@@ -358,43 +346,37 @@ export function DeveloperPermissionsPane({ const settingId = `developer-permissions-${permission.id}` return ( -
-
-
{permission.icon}
-
-
- {permission.label} - - {developerPermissionStatusLabel(status)} - -
-

{permission.description}

-
-
- +
+
{permission.icon}
+
+
+ {permission.label} + + {developerPermissionStatusLabel(permission.id, status)} + +
+

{permission.description}

+
+
+ void request(id)} + /> +
+ {permission.id === 'local-network' && }
) })} diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 852f1d84337..e230ba7b670 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -138,7 +138,7 @@ describe('ExperimentalPane', () => { ) - expect(settings.experimentalAgentDashboardPopout).toBe(false) + expect(settings.experimentalAgentDashboardPopout).toBeUndefined() expect(markup).toContain('Agent Dashboard') expect(markup).toContain('Monitor agents that need you, are working, or are done') expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain( @@ -164,29 +164,15 @@ describe('ExperimentalPane', () => { root.unmount() }) - it('exposes idle-agent visibility for pop-out dashboards', async () => { - const updateSettings = vi.fn() - const settings = { - ...getDefaultSettings('/tmp'), - experimentalAgentDashboardPopout: true - } - const { root, container } = await renderExperimentalPane({ - settings, - updateSettings - }) - const idleSwitch = container.querySelector( - '#experimental-agent-dashboard button[role="switch"][aria-label="Show idle agents"]' + it('keeps idle-agent visibility out of global settings', () => { + const markup = renderToStaticMarkup( + ) - if (!idleSwitch) { - throw new Error('Idle-agent visibility switch was not rendered') - } - await act(async () => { - idleSwitch.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(updateSettings).toHaveBeenCalledWith({ experimentalAgentDashboardShowIdle: true }) - root.unmount() + expect(markup).not.toContain('Show idle agents') }) it('renders Cloud VM as an off-by-default experimental subsection', () => { diff --git a/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx b/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx new file mode 100644 index 00000000000..ed65eeff194 --- /dev/null +++ b/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { LocalNetworkConnectionTest } from './LocalNetworkConnectionTest' + +let container: HTMLDivElement +let root: Root +const testConnectionMock = vi.fn() + +beforeEach(() => { + localStorage.clear() + testConnectionMock.mockReset() + Object.assign(window, { + api: { + developerPermissions: { + testLocalNetworkConnection: testConnectionMock + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + localStorage.clear() + Reflect.deleteProperty(window, 'api') +}) + +function inputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +async function expandConnectionTest(): Promise { + await act(async () => + container.querySelector('[data-slot="collapsible-trigger"]')?.click() + ) +} + +it('keeps the target form collapsed by default', async () => { + await act(async () => root.render()) + + expect(container.textContent).toContain('Test connection') + expect(container.textContent).toContain('No successful test saved.') + expect(container.querySelector('form')).toBeNull() +}) + +it('saves and displays the last successful target-specific test', async () => { + testConnectionMock.mockResolvedValue({ + ok: true, + host: '192.168.1.20', + port: 3000, + testedAt: new Date('2026-08-06T07:00:00Z').getTime() + }) + await act(async () => root.render()) + await expandConnectionTest() + + const host = container.querySelector('#local-network-test-host')! + const port = container.querySelector('#local-network-test-port')! + await act(async () => { + inputValue(host, '192.168.1.20') + inputValue(port, '3000') + }) + await act(async () => + container.querySelector('button[type="submit"]')?.click() + ) + + expect(testConnectionMock).toHaveBeenCalledWith({ host: '192.168.1.20', port: 3000 }) + expect(container.textContent).toContain('Last verified') + expect(container.textContent).toContain('192.168.1.20:3000') + + await act(async () => root.render(null)) + await act(async () => root.render()) + await expandConnectionTest() + expect(container.textContent).toContain('192.168.1.20:3000') +}) + +it('keeps the last success visible when a later test fails', async () => { + testConnectionMock + .mockResolvedValueOnce({ + ok: true, + host: 'devbox.local', + port: 8080, + testedAt: 1000 + }) + .mockResolvedValueOnce({ + ok: false, + host: 'devbox.local', + port: 8080, + testedAt: 2000, + failure: 'refused' + }) + await act(async () => root.render()) + await expandConnectionTest() + + const host = container.querySelector('#local-network-test-host')! + const port = container.querySelector('#local-network-test-port')! + await act(async () => { + inputValue(host, 'devbox.local') + inputValue(port, '8080') + }) + await act(async () => + container.querySelector('button[type="submit"]')?.click() + ) + await act(async () => + container.querySelector('button[type="submit"]')?.click() + ) + + expect(container.textContent).toContain('Last verified') + expect(container.textContent).toContain('devbox.local:8080') + expect(container.textContent).toContain('The host responded, but the port refused') + expect(container.textContent).not.toContain('Permission denied') +}) diff --git a/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx b/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx new file mode 100644 index 00000000000..5912b72cd01 --- /dev/null +++ b/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx @@ -0,0 +1,207 @@ +import { useState, type FormEvent } from 'react' +import { CheckCircle2, ChevronDown } from 'lucide-react' +import type { + LocalNetworkConnectionTestFailure, + LocalNetworkConnectionTestResult +} from '../../../../shared/developer-permissions-types' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { + loadLocalNetworkConnectionSuccess, + saveLocalNetworkConnectionSuccess, + type LocalNetworkConnectionSuccess +} from './local-network-connection-history' + +function failureMessage(failure: LocalNetworkConnectionTestFailure | undefined): string { + switch (failure) { + case 'invalid-target': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestInvalidTarget', + 'Enter a hostname or private LAN IP and a port from 1 to 65535.' + ) + case 'timeout': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestTimeout', + 'Connection timed out. Check the target, service, and macOS Local Network settings.' + ) + case 'refused': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestRefused', + 'The host responded, but the port refused the connection.' + ) + case 'unreachable': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnreachable', + 'The target could not be reached.' + ) + case 'unresolved': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnresolved', + 'The hostname could not be resolved.' + ) + case 'unsupported': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnsupported', + 'Connection testing is available in the macOS desktop app.' + ) + case 'failed': + case undefined: + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestFailed', + 'The connection test could not be completed.' + ) + } +} + +function formatTarget(success: LocalNetworkConnectionSuccess): string { + return `${success.host}:${success.port}` +} + +export function LocalNetworkConnectionTest(): React.JSX.Element { + const [lastSuccess, setLastSuccess] = useState(loadLocalNetworkConnectionSuccess) + const [open, setOpen] = useState(false) + const [host, setHost] = useState(lastSuccess?.host ?? '') + const [port, setPort] = useState(lastSuccess ? String(lastSuccess.port) : '') + const [running, setRunning] = useState(false) + const [failure, setFailure] = useState(null) + + const submit = async (event: FormEvent): Promise => { + event.preventDefault() + setRunning(true) + setFailure(null) + try { + const result: LocalNetworkConnectionTestResult = + await window.api.developerPermissions.testLocalNetworkConnection({ + host, + port: Number(port) + }) + if (result.ok) { + const success = { + host: result.host, + port: result.port, + testedAt: result.testedAt + } + saveLocalNetworkConnectionSuccess(success) + setLastSuccess(success) + } else { + setFailure(result.failure ?? 'failed') + } + } catch { + setFailure('failed') + } finally { + setRunning(false) + } + } + + return ( + + + + + +
+

+ {translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestDescription', + 'Enter a service on another device on your local network. Orca tests the same network path used by terminal tools.' + )} +

+ {failure && ( +

+ {failureMessage(failure)} +

+ )} +
void submit(event)}> +
+ + setHost(event.target.value)} + placeholder="192.168.1.20" + autoComplete="off" + className="w-44" + disabled={running} + /> +
+
+ + setPort(event.target.value)} + placeholder="3000" + className="w-24" + disabled={running} + /> +
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index d0f39bbeec6..73256687792 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -10,6 +10,10 @@ import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions import { ManageSessionKillDialog } from './ManageSessionKillDialog' import { ManageSessionsTable } from './ManageSessionsTable' import { notifyDaemonSessionInventoryInvalidated } from '../status-bar/daemon-session-inventory-invalidation' +import { + MANAGE_SESSIONS_SECTION_ID, + TerminalTccAttributionNotice +} from './TerminalTccAttributionNotice' import { translate } from '@/i18n/i18n' type ConfirmKind = 'killOne' @@ -20,6 +24,7 @@ export function ManageSessionsSection(): React.JSX.Element { const [hasLoadedOnce, setHasLoadedOnce] = useState(false) const [pendingKillSession, setPendingKillSession] = useState(null) const [busyKind, setBusyKind] = useState(null) + const [attributionRefreshRevision, setAttributionRefreshRevision] = useState(0) const optimisticRollback = useRef(null) const isMounted = useRef(true) const mutationInFlight = useRef(false) @@ -124,6 +129,7 @@ export function ManageSessionsSection(): React.JSX.Element { }, onRestartSettled: () => { notifyDaemonSessionInventoryInvalidated() + setAttributionRefreshRevision((revision) => revision + 1) void refresh() } }) @@ -206,7 +212,12 @@ export function ManageSessionsSection(): React.JSX.Element { description={getManageSessionsSearchEntries()[0].description} keywords={getManageSessionsSearchEntries()[0].keywords} className="space-y-3" + id={MANAGE_SESSIONS_SECTION_ID} > + { afterEach(() => cleanup()) - it('shows a compact Sign in row when Orca Relay is selected and signed out', async () => { + it('shows Sign in directly under Orca Relay, above LAN', async () => { const user = userEvent.setup() const onChange = vi.fn() render() - expect(screen.getByTestId('anywhere-sign-in-panel')).toBeVisible() - expect(screen.getByText('Sign in to use Orca Mobile Relay.')).toBeVisible() + const relay = screen.getByRole('radio', { name: /Orca Relay/i }) + const lan = screen.getByRole('radio', { name: /^LAN\b/i }) + const signInPanel = screen.getByTestId('anywhere-sign-in-panel') + const signIn = screen.getByRole('button', { name: 'Sign in for Relay' }) + expect(signInPanel).toBeVisible() + expect(screen.getByText('Relay only — LAN does not need an account.')).toBeVisible() + // Why: CTA must sit between Relay and LAN so it is not buried under LAN. + expect( + relay.compareDocumentPosition(signInPanel) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + expect(signInPanel.compareDocumentPosition(lan) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + // Why: `radiogroup` only permits `radio` children. The panel is layout-only, + // so it must stay role-less rather than declaring a `group` the group cannot + // own — and its label must not double-announce the button it wraps. + const group = screen.getByRole('radiogroup') + expect(within(group).queryAllByRole('group')).toHaveLength(0) + expect(within(group).getAllByRole('radio')).toHaveLength(2) + expect(signInPanel).not.toHaveAttribute('aria-label') // Why: do not surface build-setup diagnostics in the pairing flow. expect(screen.queryByText(/not configured for this build/i)).toBeNull() - await user.click(screen.getByRole('button', { name: 'Sign in' })) + await user.click(signIn) expect(onChange).toHaveBeenCalledWith('automatic') expect(connect).toHaveBeenCalledOnce() }) @@ -83,6 +99,7 @@ describe('MobilePairingConnectionOptions', () => { it('hides Sign in when LAN is selected', () => { render() expect(screen.queryByTestId('anywhere-sign-in-panel')).toBeNull() + expect(screen.queryByRole('button', { name: /Sign in/i })).toBeNull() }) it('shows Unavailable instead of a dead Sign in on unconfigured builds', () => { @@ -100,8 +117,37 @@ describe('MobilePairingConnectionOptions', () => { // No Relay endpoint to sign into — the Sign in CTA must not appear. expect(screen.queryByTestId('anywhere-sign-in-panel')).toBeNull() expect(screen.queryByRole('button', { name: /Sign in/i })).toBeNull() - expect(screen.getByTestId('anywhere-unavailable-panel')).toBeVisible() - expect(screen.getByText('Unavailable')).toBeVisible() + const relay = screen.getByRole('radio', { name: /Orca Relay/i }) + expect(relay).toHaveTextContent('Unavailable') + expect(relay).toHaveTextContent(/isn’t available in this build/i) + }) + + it('keeps Relay unavailable and unselectable while LAN is selected', async () => { + mocks.state = { + ...mocks.state, + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: false, + state: 'unconfigured', + persistence: 'none' + } + } + const onChange = vi.fn() + const user = userEvent.setup() + render() + + // Availability follows the build, not the selected path. + const relay = screen.getByRole('radio', { name: /Orca Relay/i }) + expect(relay).toHaveTextContent('Unavailable') + expect(relay).toHaveTextContent(/isn’t available in this build/i) + expect(relay).toHaveAttribute('aria-disabled', 'true') + + await user.click(relay) + expect(onChange).not.toHaveBeenCalled() + + screen.getByRole('radio', { name: /^LAN\b/i }).focus() + await user.keyboard('{ArrowUp}') + expect(onChange).not.toHaveBeenCalled() }) it('moves selection with the arrow keys as a radiogroup', async () => { @@ -114,17 +160,27 @@ describe('MobilePairingConnectionOptions', () => { expect(onChange).toHaveBeenCalledWith('local-only') }) + it('does not change path when arrow keys hit the Sign in control', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + + screen.getByRole('button', { name: 'Sign in for Relay' }).focus() + await user.keyboard('{ArrowDown}') + expect(onChange).not.toHaveBeenCalled() + }) + it('selects a path from the compact list', async () => { const user = userEvent.setup() const onChange = vi.fn() render() expect( - screen.getByText('Phone can be on cellular or any Wi‑Fi. Sign-in required.') + screen.getByText('Phone can be on cellular or any Wi‑Fi. Sign-in required for Relay only.') ).toBeVisible() expect( screen.getByText( - 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' + 'Phone must be on this Wi‑Fi or connected through Tailscale. No account needed.' ) ).toBeVisible() diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx index a1a88548793..1af58c45827 100644 --- a/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useEffect, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' import { Badge } from '../ui/badge' import { Button } from '../ui/button' @@ -7,6 +7,7 @@ import { useAppStore } from '../../store' import { cn } from '@/lib/utils' import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import { MobilePairingPathOption } from './MobilePairingPathOption' function relayStatusLabel(status: MobileRelayStatus): string { if (status === 'registered') { @@ -36,78 +37,6 @@ function relayStatusLabel(status: MobileRelayStatus): string { ) } -type PathOptionProps = { - selected: boolean - onSelect: () => void - title: string - description: string - trailing?: ReactNode - tabIndex: number - disabled?: boolean - optionRef?: (el: HTMLDivElement | null) => void -} - -// Why: this is a bespoke radio row rather than the canonical SettingsSegmentedControl -// because each option needs a two-line title + description plus a trailing status -// badge, which the single-line segmented pill cannot carry (STYLEGUIDE.md's -// "real difference in role" carve-out). Arrow-key nav and roving tabindex below -// keep it a conformant ARIA radiogroup. -function PathOption({ - selected, - onSelect, - title, - description, - trailing, - tabIndex, - disabled = false, - optionRef -}: PathOptionProps): React.JSX.Element { - return ( -
{ - if (disabled) { - return - } - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault() - onSelect() - } - }} - className={cn( - 'flex cursor-pointer items-start gap-3 px-3 py-2.5 outline-none transition-colors', - // Why: match SettingsFormControls focus ring so keyboard focus is visible - // even when the selected row already uses bg-accent/40. - 'focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50', - disabled && 'cursor-not-allowed opacity-60', - selected ? 'bg-accent/40' : 'hover:bg-accent/20' - )} - > - - {selected ? : null} - -
-
- {title} - {trailing} -
-

{description}

-
-
- ) -} - export function MobilePairingConnectionOptions({ value, onChange, @@ -134,7 +63,9 @@ export function MobilePairingConnectionOptions({ // and only offer Sign in when the build can actually reach Relay. const configured = authStatus?.configured !== false const needsSignIn = value === 'automatic' && !signedIn && configured - const relayUnavailable = value === 'automatic' && !signedIn && !configured + // Availability is a property of the build, not of the current selection. + const relayUnavailable = !signedIn && !configured + const relayDisabled = relayMintRetrying || relayUnavailable const optionRefs = useRef>({ automatic: null, 'local-only': null @@ -142,16 +73,22 @@ export function MobilePairingConnectionOptions({ // Why: ARIA radiogroups move selection with the arrow keys; wrap between the // two options and move focus so keyboard users get standard behavior. + // Ignore arrows that originate on nested controls (Sign in) so they do not + // steal keys from the button or flip the path while focus is outside a radio. const handleArrowKeys = (event: React.KeyboardEvent): void => { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return } - if (relayMintRetrying && value !== 'automatic') { + const target = event.target + if (!(target instanceof HTMLElement) || target.getAttribute('role') !== 'radio') { + return + } + if (relayDisabled && value !== 'automatic') { return } event.preventDefault() const next: MobilePairingConnectionMode = - relayMintRetrying || value === 'automatic' ? 'local-only' : 'automatic' + relayDisabled || value === 'automatic' ? 'local-only' : 'automatic' onChange(next) optionRefs.current[next]?.focus() } @@ -196,10 +133,12 @@ export function MobilePairingConnectionOptions({ onKeyDown={handleArrowKeys} className="overflow-hidden rounded-md border border-border" > - { optionRefs.current.automatic = el }} @@ -208,12 +147,26 @@ export function MobilePairingConnectionOptions({ 'auto.components.settings.MobilePairingConnectionOptions.anywhereTitle', 'Orca Relay' )} - description={translate( - 'auto.components.settings.MobilePairingConnectionOptions.anywhereDescription', - 'Phone can be on cellular or any Wi‑Fi. Sign-in required.' - )} + description={ + relayUnavailable + ? translate( + 'auto.components.settings.MobilePairingConnectionOptions.relayUnavailable', + 'Orca Relay isn’t available in this build. Use LAN.' + ) + : translate( + 'auto.components.settings.MobilePairingConnectionOptions.anywhereDescription', + 'Phone can be on cellular or any Wi‑Fi. Sign-in required for Relay only.' + ) + } trailing={ - signedIn && value === 'automatic' ? ( + relayUnavailable ? ( + + {translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + )} + + ) : signedIn && value === 'automatic' ? ( {relayMintRetrying ? translate( @@ -230,10 +183,59 @@ export function MobilePairingConnectionOptions({ ) : null } /> + {needsSignIn ? ( +
{ + // Why: nested controls live inside the radiogroup for layout; do not + // let arrow keys bubble and flip the selected path. + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + event.stopPropagation() + } + }} + className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-accent/40 py-2.5 pl-10 pr-3" + data-testid="anywhere-sign-in-panel" + > +

+ {translate( + 'auto.components.settings.MobilePairingConnectionOptions.signInRequired', + 'Relay only — LAN does not need an account.' + )} +

+ +
+ ) : null}
- { optionRefs.current['local-only'] = el }} @@ -244,64 +246,10 @@ export function MobilePairingConnectionOptions({ )} description={translate( 'auto.components.settings.MobilePairingConnectionOptions.localDescription', - 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' + 'Phone must be on this Wi‑Fi or connected through Tailscale. No account needed.' )} />
- - {needsSignIn ? ( -
-

- {translate( - 'auto.components.settings.MobilePairingConnectionOptions.signInRequired', - 'Sign in to use Orca Mobile Relay.' - )} -

- -
- ) : null} - - {relayUnavailable ? ( -
-

- {translate( - 'auto.components.settings.MobilePairingConnectionOptions.relayUnavailable', - 'Orca Relay isn’t available in this build. Use LAN.' - )} -

- - {translate( - 'auto.components.settings.MobilePairingConnectionOptions.unavailable', - 'Unavailable' - )} - -
- ) : null}
) } diff --git a/src/renderer/src/components/settings/MobilePairingPathOption.tsx b/src/renderer/src/components/settings/MobilePairingPathOption.tsx new file mode 100644 index 00000000000..191b584d2b9 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingPathOption.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +// Why: bespoke radio row instead of SettingsSegmentedControl — needs two-line +// title + description + trailing badge (STYLEGUIDE "real difference in role"). +export function MobilePairingPathOption({ + selected, + onSelect, + title, + description, + trailing, + tabIndex, + disabled = false, + positionInSet, + setSize, + optionRef +}: { + selected: boolean + onSelect: () => void + title: string + description: string + trailing?: ReactNode + tabIndex: number + disabled?: boolean + /** Stated explicitly: the Sign in panel sits between the radios in the DOM. */ + positionInSet: number + setSize: number + optionRef?: (el: HTMLDivElement | null) => void +}): React.JSX.Element { + return ( +
{ + if (disabled) { + return + } + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault() + onSelect() + } + }} + className={cn( + 'flex cursor-pointer items-start gap-3 px-3 py-2.5 outline-none transition-colors', + // Why: match SettingsFormControls focus ring when selected uses bg-accent/40. + 'focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50', + disabled && 'cursor-not-allowed opacity-60', + selected ? 'bg-accent/40' : 'hover:bg-accent/20' + )} + > + + {selected ? : null} + +
+
+ {title} + {trailing} +
+

{description}

+
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx index d66a9144caf..ca7e13f5b14 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -78,12 +78,48 @@ describe('MobilePairingSetupSection', () => { expect(screen.getByText(/must be able to reach this address/i)).toBeVisible() }) - it('explains the direct address when Orca Relay is selected', () => { - renderSection({ connectionMode: 'automatic' }) - expect(screen.getByText('This computer’s address')).toBeVisible() - expect(screen.getByRole('combobox')).toBeVisible() - expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible() + it('demotes this computer’s address to a disclosure when Orca Relay is selected', async () => { + const { user } = renderSection({ + connectionMode: 'automatic', + selectedAddress: undefined + }) + expect(screen.queryByText('This computer’s address')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeEnabled() + + // Relay still advertises a LAN endpoint, so the picker must stay reachable. + await user.click(screen.getByRole('button', { name: /Also use a faster local path/i })) + expect(screen.getByRole('combobox')).toBeVisible() + expect(screen.getByText(/faster than Relay/i)).toBeVisible() + }) + + it('opens the Relay address disclosure when a settings search targets it', () => { + renderSection({ + connectionMode: 'automatic', + addressDisclosureForcedOpen: true + }) + expect(screen.getByRole('combobox')).toBeVisible() + // Why: a trigger here could not collapse the pinned-open picker, so it would + // be a dead control advertising aria-expanded it does not own. + expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull() + }) + + it('never hides a custom address behind the Relay disclosure', () => { + const address = 'host.example:6768' + renderSection({ + connectionMode: 'automatic', + customAddresses: [address], + selectedAddress: address, + selectedAddressIsCustom: true + }) + expect(screen.getByRole('combobox')).toHaveTextContent(address) + expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull() + }) + + it('disables generate on LAN when no advertise address is selected', () => { + renderSection({ connectionMode: 'local-only', selectedAddress: undefined }) + expect(screen.getByText('This computer’s address')).toBeVisible() + expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeDisabled() }) it('can move retry recovery into the persistent failure notice', () => { diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index 8367b49fdbe..c398e199dee 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -1,7 +1,9 @@ -import type { ReactNode } from 'react' -import { Loader2, QrCode, RefreshCw } from 'lucide-react' +import { useState, type ReactNode } from 'react' +import { ChevronDown, Loader2, QrCode, RefreshCw } from 'lucide-react' import { Button } from '../ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker' import type { MobileNetworkInterface } from './mobile-network-interface-selection' @@ -11,6 +13,8 @@ type MobilePairingSetupSectionProps = { connectionMode: MobilePairingConnectionMode /** False when Anywhere is selected but Relay cannot be committed yet. */ canGenerate?: boolean + /** Reveals the Relay address disclosure when a settings search matches it. */ + addressDisclosureForcedOpen?: boolean connectionPathControl: ReactNode networkInterfaces: MobileNetworkInterface[] customAddresses: readonly string[] @@ -30,6 +34,7 @@ type MobilePairingSetupSectionProps = { export function MobilePairingSetupSection({ connectionMode, canGenerate = true, + addressDisclosureForcedOpen = false, connectionPathControl, networkInterfaces, customAddresses, @@ -46,7 +51,72 @@ export function MobilePairingSetupSection({ onGenerateQr }: MobilePairingSetupSectionProps): React.JSX.Element { const usingRelay = connectionMode === 'automatic' - const generateDisabled = loading || !selectedAddress || !canGenerate + const [addressDisclosureOpen, setAddressDisclosureOpen] = useState(false) + // A search hit or a custom address pins the picker open: render it outright + // rather than behind a trigger that could not collapse it anyway. + const addressDisclosurePinned = addressDisclosureForcedOpen || selectedAddressIsCustom + // Relay offers still carry a LAN endpoint for the direct fast path, but main + // substitutes its own default when the renderer has not resolved one yet. + // LAN has no such fallback, so it needs an explicit reachable host. + const generateDisabled = loading || !canGenerate || (!usingRelay && !selectedAddress) + // "Also use…" frames this as additive under Relay, not a second connection mode. + // The phone races both paths and direct wins ties when nearby. + const relayAddressLabel = translate( + 'auto.components.settings.MobilePairingSetupSection.step2RelayDisclosure', + 'Also use a faster local path' + ) + + const addressControls = ( +
+
+ + + + + + + {translate( + 'auto.components.settings.MobilePairingSetupSection.refresh', + 'Refresh network interfaces' + )} + + +
+

+ {usingRelay + ? translate( + 'auto.components.settings.MobilePairingSetupSection.step2RelayDescription', + 'Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.' + ) + : translate( + 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', + 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' + )} +

+
+ ) return (
@@ -69,61 +139,50 @@ export function MobilePairingSetupSection({ {connectionPathControl}
-
-

- {translate( - 'auto.components.settings.MobilePairingSetupSection.step2Title', - 'This computer’s address' - )} -

-
- - - - - - - {translate( - 'auto.components.settings.MobilePairingSetupSection.refresh', - 'Refresh network interfaces' - )} - - + {usingRelay && addressDisclosurePinned ? ( +
+

{relayAddressLabel}

+
+ {addressControls} +
-

- {usingRelay - ? translate( - 'auto.components.settings.MobilePairingSetupSection.step2RelayDescription', - 'Used for a faster direct path when nearby. Relay covers remote access.' - ) - : translate( - 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', - 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' - )} -

-
+ ) : usingRelay ? ( + // Why: Relay makes the address optional, not irrelevant — demote it to a + // disclosure so the direct fast path stays reachable without clutter. + + + + + +
+ {addressControls} +
+
+
+ ) : ( +
+

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.step2Title', + 'This computer’s address' + )} +

+ {addressControls} +
+ )} {showGenerateAction ? (
diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 948cc96615d..1695263a740 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -23,6 +23,7 @@ type PairedDevicesProps = { type StoreState = { orcaProfileAuthStatus: { state: 'connected' | 'local' } + settingsSearchQuery: string settings: { mobileAutoRestoreFitMs: number | null mobilePairingConnectionMode?: MobilePairingConnectionMode @@ -155,7 +156,11 @@ vi.mock('./MobilePairedDevicesSection', () => ({ } })) vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () =>
})) -vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ + WindowsFirewallNotice: (props: { usingRelay?: boolean }) => ( +
{String(props.usingRelay)}
+ ) +})) import { MobilePane } from './MobilePane' @@ -180,6 +185,7 @@ describe('MobilePane pairing connection mode', () => { updateSettings.mockReset().mockResolvedValue(undefined) mocks.holder.state = { orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', settings: { mobileAutoRestoreFitMs: null }, updateSettings, recordFeatureInteraction: vi.fn() @@ -475,6 +481,17 @@ describe('MobilePane pairing connection mode', () => { expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') }) + it('keeps the firewall notice on Relay, which still uses the direct LAN path', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + expect(screen.getByTestId('firewall-notice')).toHaveTextContent('true') + + await user.click(screen.getByRole('button', { name: 'choose-local' })) + expect(screen.getByTestId('firewall-notice')).toHaveTextContent('false') + }) + it('keeps the current pairing code when only custom address intent changes', async () => { const address = '100.126.117.25:6768' mocks.holder.state.settings = { @@ -777,6 +794,7 @@ describe('MobilePane', () => { mocks.updateSettings.mockReset().mockResolvedValue(undefined) mocks.holder.state = { orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', settings: { mobileAutoRestoreFitMs: null }, updateSettings: mocks.updateSettings, recordFeatureInteraction: vi.fn() diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 00fb7f34399..8dafdf21cef 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -24,6 +24,7 @@ import { import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode' import { useMobilePairingAddressPreference } from '../mobile/use-mobile-pairing-address-preference' +import { shouldOpenMobilePairingAddress } from './mobile-pane-search' export { getMobilePaneSearchEntries } from './mobile-pane-search' export function MobilePane(): React.JSX.Element { @@ -41,6 +42,7 @@ export function MobilePane(): React.JSX.Element { const [codeCopied, setCodeCopied] = useState(false) const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') + const settingsSearchQuery = useAppStore((state) => state.settingsSearchQuery) const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() const [rotateNextQr, setRotateNextQr] = useState(false) const codeCopiedResetTimerRef = useRef(null) @@ -386,6 +388,7 @@ export function MobilePane(): React.JSX.Element { - + Promise + recordFeatureInteraction: () => void +} + +const mocks = vi.hoisted(() => { + const holder: { state: StoreState } = { state: {} as StoreState } + const useAppStore = Object.assign( + (selector: (state: StoreState) => unknown) => selector(holder.state), + { getState: () => holder.state } + ) + return { holder, useAppStore } +}) + +vi.mock('@/store', () => ({ useAppStore: mocks.useAppStore })) +vi.mock('../../store', () => ({ useAppStore: mocks.useAppStore })) +// `i18n` is read by the localized search catalogs the pane consults to decide +// whether a query targets the address picker. +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback, + i18n: { language: 'en' } +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('./mobile-pairing-device-polling', () => ({ useMobilePairingDevicePolling: vi.fn() })) +vi.mock('./MobilePairingSetupSection', () => ({ + MobilePairingSetupSection: (props: { addressDisclosureForcedOpen?: boolean }) => ( +
{String(props.addressDisclosureForcedOpen)}
+ ) +})) +vi.mock('./MobilePairingConnectionOptions', () => ({ + MobilePairingConnectionOptions: () =>
+})) +vi.mock('./MobilePairingQrSection', () => ({ MobilePairingQrSection: () =>
})) +vi.mock('./MobilePairedDevicesSection', () => ({ MobilePairedDevicesSection: () =>
})) +vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) + +import { MobilePane } from './MobilePane' + +describe('MobilePane address disclosure search wiring', () => { + beforeEach(() => { + mocks.holder.state = { + orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', + settings: { mobileAutoRestoreFitMs: null }, + updateSettings: vi.fn().mockResolvedValue(undefined), + recordFeatureInteraction: vi.fn() + } + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getPairingQR: vi.fn(), + listDevices: vi.fn().mockResolvedValue({ devices: [] }), + listNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }), + revokeDevice: vi.fn() + } + } + }) + }) + + afterEach(() => cleanup()) + + // Why: an empty query matches every catalog entry, so a missing blank guard + // would spring the disclosure open the moment Settings mounts. + it.each([ + ['', 'false'], + ['revoke', 'false'], + ['tailscale', 'true'], + ['Network Interface', 'true'] + ])('forces the disclosure open for %j: %s', (query, forced) => { + mocks.holder.state.settingsSearchQuery = query + render() + expect(screen.getByTestId('address-forced-open')).toHaveTextContent(forced) + }) +}) diff --git a/src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx b/src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx new file mode 100644 index 00000000000..28e93531fca --- /dev/null +++ b/src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + MANAGE_SESSIONS_SECTION_ID, + TerminalTccAttributionNotice +} from './TerminalTccAttributionNotice' + +const openSettingsTarget = vi.fn() +const openSettingsPage = vi.fn() +const setSettingsSearchQuery = vi.fn() + +vi.mock('../../store', () => ({ + useAppStore: ( + selector: (state: { + openSettingsTarget: typeof openSettingsTarget + openSettingsPage: typeof openSettingsPage + setSettingsSearchQuery: typeof setSettingsSearchQuery + }) => unknown + ) => selector({ openSettingsTarget, openSettingsPage, setSettingsSearchQuery }) +})) + +let container: HTMLDivElement +let root: Root + +function stubAttributionHealth(health: 'intact' | 'severed' | 'unknown'): void { + Object.assign(window, { + api: { + pty: { + management: { + macTccAttribution: vi.fn(async () => ({ health })) + } + } + } + }) +} + +beforeEach(() => { + openSettingsTarget.mockClear() + openSettingsPage.mockClear() + setSettingsSearchQuery.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + Reflect.deleteProperty(window, 'api') +}) + +it('renders the remedy banner only while attribution is severed', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + const alert = container.querySelector('[role="alert"]') + expect(alert?.textContent).toContain('macOS permission grants aren’t reaching terminals') + expect(alert?.textContent).toContain('-25211') + + stubAttributionHealth('intact') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() +}) + +it('navigates to Manage Sessions from the banner action', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + + const button = container.querySelector('button') + expect(button?.textContent).toContain('Open Manage Sessions') + await act(async () => { + button?.click() + }) + + expect(setSettingsSearchQuery).toHaveBeenCalledWith('') + expect(openSettingsTarget).toHaveBeenCalledWith({ + pane: 'terminal', + repoId: null, + sectionId: MANAGE_SESSIONS_SECTION_ID + }) + expect(openSettingsPage).toHaveBeenCalled() +}) + +it('hides the navigation button on the Manage Sessions surface itself', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).not.toBeNull() + expect(container.querySelector('button')).toBeNull() +}) + +it('refreshes the warning after the daemon restart remedy settles', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).not.toBeNull() + + stubAttributionHealth('intact') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() +}) + +it('fails closed when the attribution probe is unavailable', async () => { + Object.assign(window, { api: { pty: {} } }) + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() +}) diff --git a/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx b/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx new file mode 100644 index 00000000000..0165eca5f4f --- /dev/null +++ b/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from 'react' +import { TriangleAlert } from 'lucide-react' +import { Button } from '../ui/button' +import { useAppStore } from '../../store' +import { translate } from '@/i18n/i18n' + +export const MANAGE_SESSIONS_SECTION_ID = 'terminal-manage-sessions' + +/** + * Why this exists: macOS pins the TCC "responsible process" of the detached terminal + * daemon to the app binary that forked it. Once that binary is deleted (packaged + * updates replace the bundle), Accessibility/Automation grants on Orca silently stop + * covering every daemon-hosted terminal (osascript -25211) with no OS-side signal — + * so the remedy has to be surfaced here, next to the permissions it breaks (STA-3491). + */ +export function useMacTccAttributionSevered(refreshRevision = 0): boolean { + const [severed, setSevered] = useState(false) + + const refresh = useCallback(async (): Promise => { + try { + const { health } = await window.api.pty.management.macTccAttribution() + setSevered(health === 'severed') + } catch { + setSevered(false) + } + }, []) + + useEffect(() => { + void refresh() + // Why: a daemon restart or drain changes the verdict without a pane remount. + const onFocus = (): void => { + void refresh() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [refresh, refreshRevision]) + + return severed +} + +export function TerminalTccAttributionNotice(props: { + /** The Manage Sessions surface hosts the fix itself, so it hides the navigation button. */ + showManageSessionsButton?: boolean + /** Increment after a daemon replacement attempt so the remedy state is re-checked. */ + refreshRevision?: number +}): React.JSX.Element | null { + const severed = useMacTccAttributionSevered(props.refreshRevision) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) + + if (!severed) { + return null + } + + const openManageSessions = (): void => { + // Why: a stale Settings search would hide the Manage Sessions section this points at. + setSettingsSearchQuery('') + openSettingsTarget({ + pane: 'terminal', + repoId: null, + sectionId: MANAGE_SESSIONS_SECTION_ID + }) + openSettingsPage() + } + + return ( +
+
+ +
+

+ {translate( + 'auto.components.settings.TerminalTccAttributionNotice.title', + 'macOS permission grants aren’t reaching terminals' + )} +

+

+ {translate( + 'auto.components.settings.TerminalTccAttributionNotice.body', + 'The terminal daemon was started by an Orca install that no longer exists, so macOS can’t attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.' + )} +

+
+
+ {props.showManageSessionsButton !== false && ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/developer-permission-request-notice.ts b/src/renderer/src/components/settings/developer-permission-request-notice.ts new file mode 100644 index 00000000000..47b7e79b680 --- /dev/null +++ b/src/renderer/src/components/settings/developer-permission-request-notice.ts @@ -0,0 +1,56 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types' + +/** Reports only permission outcomes supported by the corresponding macOS API. */ +export function showDeveloperPermissionRequestNotice( + result: DeveloperPermissionRequestResult, + openLocalNetworkSettings: () => void +): void { + if (result.status === 'granted') { + toast.success( + translate( + 'auto.components.settings.DeveloperPermissionsPane.48d87edcd2', + 'Permission granted' + ) + ) + return + } + if (result.openedSystemSettings) { + toast.message( + translate( + 'auto.components.settings.DeveloperPermissionsPane.fa809e8ada', + 'Opened macOS Privacy & Security' + ) + ) + return + } + if (result.id === 'local-network') { + toast.message( + translate( + 'auto.components.settings.DeveloperPermissionsPane.localNetworkPromptCheck', + 'Check for a macOS prompt' + ), + { + description: translate( + 'auto.components.settings.DeveloperPermissionsPane.localNetworkPromptGuidance', + 'If prompted, choose Allow. If no prompt appears, open System Settings and enable Orca under Privacy & Security → Local Network.' + ), + action: { + label: translate( + 'auto.components.settings.DeveloperPermissionsPane.localNetworkOpenSettings', + 'Open System Settings' + ), + onClick: openLocalNetworkSettings + } + } + ) + return + } + toast.message( + translate( + 'auto.components.settings.DeveloperPermissionsPane.66e94d6cf3', + 'Permission request sent' + ) + ) +} diff --git a/src/renderer/src/components/settings/developer-permission-status.ts b/src/renderer/src/components/settings/developer-permission-status.ts index 7da0b4796f1..c7f4ed5f510 100644 --- a/src/renderer/src/components/settings/developer-permission-status.ts +++ b/src/renderer/src/components/settings/developer-permission-status.ts @@ -1,8 +1,12 @@ import { translate } from '@/i18n/i18n' -import type { DeveloperPermissionStatus } from '../../../../shared/developer-permissions-types' +import type { + DeveloperPermissionId, + DeveloperPermissionStatus +} from '../../../../shared/developer-permissions-types' /** Status copy and tone for the macOS developer permission rows. */ export function developerPermissionStatusLabel( + id: DeveloperPermissionId, status: DeveloperPermissionStatus | undefined ): string { switch (status) { @@ -32,6 +36,12 @@ export function developerPermissionStatusLabel( ) case 'unknown': case undefined: + if (id === 'local-network') { + return translate( + 'auto.components.settings.DeveloperPermissionsPane.statusManagedByMacOS', + 'Managed by macOS' + ) + } return translate( 'auto.components.settings.DeveloperPermissionsPane.statusCheckManually', 'Check manually' diff --git a/src/renderer/src/components/settings/local-network-connection-history.ts b/src/renderer/src/components/settings/local-network-connection-history.ts new file mode 100644 index 00000000000..8712c065d6a --- /dev/null +++ b/src/renderer/src/components/settings/local-network-connection-history.ts @@ -0,0 +1,45 @@ +import type { LocalNetworkConnectionTestResult } from '../../../../shared/developer-permissions-types' + +const STORAGE_KEY = 'orca.developer-permissions.local-network-last-success.v1' + +export type LocalNetworkConnectionSuccess = Pick< + LocalNetworkConnectionTestResult, + 'host' | 'port' | 'testedAt' +> + +function isSavedSuccess(value: unknown): value is LocalNetworkConnectionSuccess { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Partial + return ( + typeof candidate.host === 'string' && + candidate.host.length > 0 && + Number.isInteger(candidate.port) && + candidate.port! >= 1 && + candidate.port! <= 65_535 && + typeof candidate.testedAt === 'number' && + Number.isFinite(candidate.testedAt) + ) +} + +export function loadLocalNetworkConnectionSuccess(): LocalNetworkConnectionSuccess | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) { + return null + } + const parsed: unknown = JSON.parse(raw) + return isSavedSuccess(parsed) ? parsed : null + } catch { + return null + } +} + +export function saveLocalNetworkConnectionSuccess(success: LocalNetworkConnectionSuccess): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(success)) + } catch { + // The live result remains useful when browser storage is unavailable. + } +} diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts index 0feaeaeeaef..7fa81a72b37 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts @@ -6,6 +6,7 @@ import { const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } +const BRIDGE: MobileNetworkInterface = { name: 'docker0', address: '172.17.0.1' } describe('selectRefreshedNetworkAddress', () => { // Why: regression for the manual-address branch the PR adds — a @@ -38,4 +39,18 @@ describe('selectRefreshedNetworkAddress', () => { it('clears the selection when no interfaces are available', () => { expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined() }) + + it('skips a container bridge in favor of a reachable address', () => { + expect(selectRefreshedNetworkAddress(undefined, [BRIDGE, LAN])).toBe(LAN.address) + }) + + // Why: matches main's default — advertising nothing beats advertising docker0, and Relay + // pairs without a direct address. A divergence here would show an address the QR never carried. + it('selects no address when every interface is a container bridge', () => { + expect(selectRefreshedNetworkAddress(undefined, [BRIDGE])).toBeUndefined() + }) + + it('keeps a bridge the user picked explicitly', () => { + expect(selectRefreshedNetworkAddress(BRIDGE.address, [BRIDGE, LAN])).toBe(BRIDGE.address) + }) }) diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.ts index 2edd817b68f..4b9e4782740 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.ts @@ -1,4 +1,4 @@ -import { isTailnetIPv4Address } from '../../../../shared/tailnet-address' +import { selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection' export type MobileNetworkInterface = { name: string @@ -25,8 +25,7 @@ export function selectRefreshedNetworkAddress( ) { return currentAddress } - return ( - interfaces.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? - interfaces[0]!.address - ) + // Why: shared with main so the picker never shows a default the QR didn't advertise. Undefined on + // a bridge-only host: Relay pairs without a direct address rather than offering an unreachable one. + return selectAutoAdvertisedPairingAddress(interfaces) } diff --git a/src/renderer/src/components/settings/mobile-pane-search.test.ts b/src/renderer/src/components/settings/mobile-pane-search.test.ts new file mode 100644 index 00000000000..fb909663e9f --- /dev/null +++ b/src/renderer/src/components/settings/mobile-pane-search.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { getMobilePaneSearchEntries, shouldOpenMobilePairingAddress } from './mobile-pane-search' + +describe('getMobilePaneSearchEntries', () => { + // Why: the network entries were split into their own catalog and spliced back + // in. Search ranking breaks ties by index, so a reorder silently reranks rows. + it('keeps Network Interface in its original catalog position', () => { + expect(getMobilePaneSearchEntries().map((entry) => entry.title)).toEqual([ + 'Mobile Pairing', + 'Connected Devices', + 'Network Interface', + 'When you leave the mobile app' + ]) + }) +}) + +describe('shouldOpenMobilePairingAddress', () => { + it('stays closed for an empty or blank query', () => { + // Why: an empty query scores as a match for every entry, so the disclosure + // would spring open the moment Settings mounts without a search. + expect(shouldOpenMobilePairingAddress('')).toBe(false) + expect(shouldOpenMobilePairingAddress(' ')).toBe(false) + }) + + it('opens for queries that target the address picker', () => { + expect(shouldOpenMobilePairingAddress('Network Interface')).toBe(true) + expect(shouldOpenMobilePairingAddress('tailscale')).toBe(true) + expect(shouldOpenMobilePairingAddress(' LAN ')).toBe(true) + }) + + it('stays closed for queries aimed at other rows on the pane', () => { + expect(shouldOpenMobilePairingAddress('qr')).toBe(false) + expect(shouldOpenMobilePairingAddress('revoke')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/settings/mobile-pane-search.ts b/src/renderer/src/components/settings/mobile-pane-search.ts index 669a22a5ce9..576b0f86f8d 100644 --- a/src/renderer/src/components/settings/mobile-pane-search.ts +++ b/src/renderer/src/components/settings/mobile-pane-search.ts @@ -1,43 +1,9 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' -export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ - { - title: translate('auto.components.settings.mobile.pane.search.d49925710a', 'Mobile Pairing'), - description: translate( - 'auto.components.settings.mobile.pane.search.7fb728fb2b', - 'Pair a mobile device by scanning a QR code.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.3c1807a81a', 'qr'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.4a0c826f3d', 'code'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.e518cbd61c', 'pair'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.ad08035c5f', 'phone'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.2128a21096', 'scan') - ] - }, - { - title: translate('auto.components.settings.mobile.pane.search.9d3a9397ba', 'Connected Devices'), - description: translate( - 'auto.components.settings.mobile.pane.search.13419718b3', - 'Manage paired mobile devices.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), - ...translateSearchKeyword( - 'auto.components.settings.mobile.pane.search.82783d9b71', - 'devices' - ), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.905c65a308', 'revoke'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.5e8fda4d7f', 'paired'), - ...translateSearchKeyword( - 'auto.components.settings.mobile.pane.search.7d01f93ec0', - 'connected' - ) - ] - }, +const getNetworkInterfaceSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.mobile.pane.search.d96c315227', 'Network Interface'), description: translate( @@ -75,7 +41,54 @@ export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.mobile.pane.search.70f505f3c3', 'lan'), ...translateSearchKeyword('auto.components.settings.mobile.pane.search.126afc5dbd', 'remote') ] + } +]) + +/** Reveal the Relay address disclosure when a search targets the picker. */ +export function shouldOpenMobilePairingAddress(searchQuery: string): boolean { + return ( + normalizeSettingsSearchQuery(searchQuery) !== '' && + matchesSettingsSearch(searchQuery, getNetworkInterfaceSearchEntries()) + ) +} + +export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.mobile.pane.search.d49925710a', 'Mobile Pairing'), + description: translate( + 'auto.components.settings.mobile.pane.search.7fb728fb2b', + 'Pair a mobile device by scanning a QR code.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.3c1807a81a', 'qr'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.4a0c826f3d', 'code'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.e518cbd61c', 'pair'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.ad08035c5f', 'phone'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.2128a21096', 'scan') + ] }, + { + title: translate('auto.components.settings.mobile.pane.search.9d3a9397ba', 'Connected Devices'), + description: translate( + 'auto.components.settings.mobile.pane.search.13419718b3', + 'Manage paired mobile devices.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), + ...translateSearchKeyword( + 'auto.components.settings.mobile.pane.search.82783d9b71', + 'devices' + ), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.905c65a308', 'revoke'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.5e8fda4d7f', 'paired'), + ...translateSearchKeyword( + 'auto.components.settings.mobile.pane.search.7d01f93ec0', + 'connected' + ) + ] + }, + ...getNetworkInterfaceSearchEntries(), { title: translate( 'auto.components.settings.mobile.pane.search.1e711aca11', diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx new file mode 100644 index 00000000000..5eeae6f145c --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx @@ -0,0 +1,90 @@ +import { LayoutDashboard, MessageCircleQuestion } from 'lucide-react' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' +import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' +import { translate } from '@/i18n/i18n' + +const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { + working: 'bg-yellow-500', + done: 'bg-emerald-500', + idle: 'bg-neutral-500/50' +} + +function dashboardBucketLabel(bucket: DashboardBucket): string { + switch (bucket) { + case 'attention': + return translate('dashboardPopout.bucket.attention', 'Needs You') + case 'working': + return translate('dashboardPopout.bucket.working', 'Working') + case 'done': + return translate('dashboardPopout.bucket.done', 'Done') + case 'idle': + return translate('dashboardPopout.bucket.idle', 'Idle') + } +} + +function DashboardBucketCounts({ + counts, + showIdle +}: { + counts: Record + showIdle: boolean +}): React.JSX.Element | null { + const active = DASHBOARD_BUCKET_ORDER.filter( + (bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle) + ) + if (active.length === 0) { + return null + } + return ( + + {active.map((bucket) => ( + + {bucket === 'attention' ? ( + + ) : ( + + )} + {counts[bucket]} + + ))} + + ) +} + +export default function AgentDashboardSidebarEntry(): React.JSX.Element { + const dashboardBucketCounts = useAgentBucketCounts() + const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) + const openAsPopout = useAppStore((s) => s.settings?.experimentalAgentDashboardMode === 'popout') + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + + return ( + + ) +} diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx new file mode 100644 index 00000000000..1b90f264c3e --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import AgentDashboardSidebarHost from './AgentDashboardSidebarHost' + +vi.mock('@/components/dashboard/AgentDashboardDrawer', () => ({ + AgentDashboardDrawer: () => null +})) + +const initialState = useAppStore.getInitialState() + +beforeEach(() => { + useAppStore.setState({ agentDashboardDrawerOpen: false }, false) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('AgentDashboardSidebarHost', () => { + it('closes the dashboard when the workspace board opens', async () => { + useAppStore.setState({ agentDashboardDrawerOpen: true }) + render( + + ) + + await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)) + }) + + it('closes the workspace board when the dashboard opens', async () => { + const closeWorkspaceBoard = vi.fn() + const view = render( + + ) + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + view.rerender( + + ) + + await waitFor(() => expect(closeWorkspaceBoard).toHaveBeenCalledOnce()) + }) + + it('clears an open dashboard when the sidebar closes', async () => { + useAppStore.setState({ agentDashboardDrawerOpen: true }) + render( + + ) + + await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)) + }) +}) diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx new file mode 100644 index 00000000000..222571dc0be --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer' + +type AgentDashboardSidebarHostProps = { + sidebarOpen: boolean + workspaceBoardOpen: boolean + closeWorkspaceBoard: () => void + leftSidebarStyle?: React.CSSProperties + statusBarVisible: boolean +} + +/** Opt-in dashboard coordination stays outside the normal sidebar path. */ +export default function AgentDashboardSidebarHost({ + sidebarOpen, + workspaceBoardOpen, + closeWorkspaceBoard, + leftSidebarStyle, + statusBarVisible +}: AgentDashboardSidebarHostProps): React.JSX.Element | null { + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + + useEffect(() => { + if (!sidebarOpen && drawerOpen) { + setDrawerOpen(false) + } + }, [drawerOpen, setDrawerOpen, sidebarOpen]) + useEffect(() => { + if (drawerOpen) { + closeWorkspaceBoard() + } + }, [closeWorkspaceBoard, drawerOpen]) + useEffect(() => { + if (workspaceBoardOpen) { + setDrawerOpen(false) + } + }, [setDrawerOpen, workspaceBoardOpen]) + + return sidebarOpen ? ( + + ) : null +} diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index 36db65daeb1..6704020676b 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -219,63 +219,4 @@ describe('Sidebar', () => { expect(fetchAllWorktrees).not.toHaveBeenCalled() }) - - describe('companion board mutual exclusion', () => { - function renderWithDashboardOpen(): { - view: ReturnType - setAgentDashboardDrawerOpen: ReturnType - } { - setSidebarState(getDefaultSettings(tmpdir())) - const setAgentDashboardDrawerOpen = vi.fn() - mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true, setAgentDashboardDrawerOpen } - mocks.panel = { - workspaceBoardOpen: false, - workspaceBoardRenderedOpen: false, - workspaceBoardDragPreviewOpen: false - } - return { view: render(sidebarElement()), setAgentDashboardDrawerOpen } - } - - it('keeps the agent dashboard open while a worktree drag only previews the board', () => { - const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen() - - mocks.panel = { - workspaceBoardOpen: false, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: true - } - view.rerender(sidebarElement()) - - expect(setAgentDashboardDrawerOpen).not.toHaveBeenCalled() - }) - - it('closes the agent dashboard when the workspace board is actually opened', () => { - const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen() - - mocks.panel = { - workspaceBoardOpen: true, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: false - } - view.rerender(sidebarElement()) - - expect(setAgentDashboardDrawerOpen).toHaveBeenCalledWith(false) - }) - - it('closes the workspace board when the agent dashboard opens', () => { - setSidebarState(getDefaultSettings(tmpdir())) - mocks.panel = { - workspaceBoardOpen: true, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: false - } - const view = render(sidebarElement()) - mocks.closeWorkspaceBoard.mockClear() - - mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true } - view.rerender(sidebarElement()) - - expect(mocks.closeWorkspaceBoard).toHaveBeenCalled() - }) - }) }) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 7d041bfb264..9d72ab4d489 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -2,6 +2,7 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { getDefaultSettings } from '../../../../shared/constants' @@ -21,6 +22,7 @@ const mocks = vi.hoisted(() => ({ checkLinearConnection: vi.fn(), hasPairedMobileDevice: false, agentBucketCounts: { attention: 0, working: 0, done: 0, idle: 0 }, + getAgentBucketCounts: vi.fn(), dismissMobileOnboardingBadge: vi.fn(), setSetupGuideSidebarDismissed: vi.fn() })) @@ -41,7 +43,10 @@ vi.mock('@/components/activity/useActivityUnreadCount', () => ({ })) vi.mock('@/components/dashboard/useAgentBucketCounts', () => ({ - useAgentBucketCounts: () => mocks.agentBucketCounts + useAgentBucketCounts: () => { + mocks.getAgentBucketCounts() + return mocks.agentBucketCounts + } })) vi.mock('@/hooks/useShortcutLabel', () => ({ @@ -243,6 +248,7 @@ describe('SidebarNav', () => { const container = await renderSidebarNav() expect(queryButtonByText(container, 'Agent Dashboard')).toBeNull() + expect(mocks.getAgentBucketCounts).not.toHaveBeenCalled() }) it('mounts the Agent Dashboard row after opt-in', async () => { @@ -254,7 +260,8 @@ describe('SidebarNav', () => { }) const container = await renderSidebarNav() - expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull() + await waitFor(() => expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull()) + expect(mocks.getAgentBucketCounts).toHaveBeenCalledTimes(1) }) it('uses a question glyph only for the Needs You count', async () => { @@ -268,6 +275,9 @@ describe('SidebarNav', () => { }) const container = await renderSidebarNav() + await waitFor(() => + expect(container.querySelector('[aria-label="Needs You: 2"]')).not.toBeNull() + ) const attention = container.querySelector('[aria-label="Needs You: 2"]') const working = container.querySelector('[aria-label="Working: 3"]') const done = container.querySelector('[aria-label="Done: 1"]') diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index d2a36fb79e4..b14295d3714 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,19 +1,9 @@ import React from 'react' -import { - Bell, - CalendarClock, - EyeOff, - LayoutDashboard, - MessageCircleQuestion, - Search, - Smartphone -} from 'lucide-react' +import { Bell, CalendarClock, EyeOff, Search, Smartphone } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { GlobalSettings } from '../../../../shared/types' -import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' -import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -25,6 +15,7 @@ import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry' import { SidebarTaskNavButton } from './SidebarTaskNavButton' import { HideSidebarMenu } from './sidebar-nav-controls' import { translate } from '@/i18n/i18n' +import { lazyWithRetry } from '@/lib/lazy-with-retry' export { getSetupGuideSidebarEntryReady, shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry' @@ -40,14 +31,6 @@ export function shouldShowAgentDashboardButton( return settings?.experimentalAgentDashboardPopout === true } -// Why: in-window is the default surface; only an explicit 'popout' choice opens -// the separate OS window. -function isAgentDashboardPopoutMode( - settings: Pick | null | undefined -): boolean { - return settings?.experimentalAgentDashboardMode === 'popout' -} - export function shouldShowMobileButton( settings: Pick | null | undefined ): boolean { @@ -60,93 +43,7 @@ export function shouldShowAutomationsButton( return settings?.showAutomationsButton !== false } -const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { - working: 'bg-yellow-500', - done: 'bg-emerald-500', - idle: 'bg-neutral-500/50' -} - -function dashboardBucketLabel(bucket: DashboardBucket): string { - switch (bucket) { - case 'attention': - return translate('dashboardPopout.bucket.attention', 'Needs You') - case 'working': - return translate('dashboardPopout.bucket.working', 'Working') - case 'done': - return translate('dashboardPopout.bucket.done', 'Done') - case 'idle': - return translate('dashboardPopout.bucket.idle', 'Idle') - } -} - -function DashboardBucketCounts({ - counts, - showIdle -}: { - counts: Record - showIdle: boolean -}): React.JSX.Element | null { - const active = DASHBOARD_BUCKET_ORDER.filter( - (bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle) - ) - if (active.length === 0) { - return null - } - return ( - - {active.map((bucket) => ( - - {bucket === 'attention' ? ( - - ) : ( - - )} - {counts[bucket]} - - ))} - - ) -} - -// Why: keep the dashboard's broad aggregate subscriptions out of SidebarNav so -// agent-status churn only updates this opt-in row, not the full navigation. -function AgentDashboardSidebarEntry(): React.JSX.Element { - const dashboardBucketCounts = useAgentBucketCounts() - const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) - const openAsPopout = useAppStore((s) => isAgentDashboardPopoutMode(s.settings)) - const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) - const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) - - return ( - - ) -} +const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry')) const SidebarNav = React.memo(function SidebarNav() { // Why: this memo boundary needs its own language subscription, while @@ -159,8 +56,13 @@ const SidebarNav = React.memo(function SidebarNav() { const openModal = useAppStore((s) => s.openModal) const updateSettings = useAppStore((s) => s.updateSettings) const activeView = useAppStore((s) => s.activeView) - const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings)) - const showAgentDashboardButton = useAppStore((s) => shouldShowAgentDashboardButton(s.settings)) + const experimentalSidebarButtons = useAppStore( + (s) => + (shouldShowAgentsButton(s.settings) ? 1 : 0) | + (shouldShowAgentDashboardButton(s.settings) ? 2 : 0) + ) + const showAgentsButton = (experimentalSidebarButtons & 1) !== 0 + const showAgentDashboardButton = (experimentalSidebarButtons & 2) !== 0 const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings)) const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings)) const automationsActive = activeView === 'automations' @@ -211,7 +113,11 @@ const SidebarNav = React.memo(function SidebarNav() { ) : null} - {showAgentDashboardButton ? : null} + {showAgentDashboardButton ? ( + + + + ) : null} {showAgentsButton ? (
) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 8f204527c9a..8911c1783c5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -192,7 +192,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('PR checks: Failed') }) - it('keeps the active dot ahead of PR status when new card style is on', () => { + it('uses PR status instead of the quiet active dot when new card style is on', () => { const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Active') + expect(markup).toContain('PR checks: Failed') expect(markup).toContain('inline-flex size-5 items-center justify-center') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps the emerald activity dot ahead of branch identity when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('lucide-git-branch') + expect(markup).toContain('size-[13px] translate-x-px') + expect(markup).toContain('text-rose-500/85') + expect(markup).not.toContain('bg-emerald-500') }) it('uses the unified compact review glyph for GitLab MR status', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { expect(markup).not.toContain('lucide-git-merge') }) - it('keeps the done dot ahead of PR status when new card style is on', () => { + it('uses PR status instead of the quiet done dot when new card style is on', () => { mocks.status = 'done' const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Done') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') + expect(markup).toContain('PR checks: Failed') + expect(markup).not.toContain('bg-emerald-500') }) it('uses PR status instead of the inactive dot when new card style is on', () => { @@ -298,8 +277,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses a branch icon with branch-only tooltip copy on quiet rows', () => { - mocks.status = 'inactive' + it('uses a branch icon with branch-only tooltip copy by default', () => { const markup = renderToStaticMarkup( { }) it('uses context-aware branch or folder path tooltip copy', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { }) it('overlays an unread badge on PR status when new card style is on', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { }) it('overlays an unread badge on the branch icon in new card style', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( (['inactive']) +const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set(['active', 'done', 'inactive']) // Why: a missing review display can also mean provider state is unavailable, // so the passive label names the identity cue without claiming no review exists. function getDefaultBranchIdentityLabel(): string { @@ -104,13 +103,16 @@ export function WorktreeCardStatusSlot({ const status = useWorktreeActivityStatus(worktreeId) const statusLabel = getWorktreeStatusLabel(status) || status const canShowReviewStatus = - newCardStyle && showStatus && prDisplay !== null && IDENTITY_REPLACEABLE_STATUSES.has(status) + newCardStyle && + showStatus && + prDisplay !== null && + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const canShowBranchStatus = newCardStyle && showStatus && hasBranchIdentity && prDisplay === null && - IDENTITY_REPLACEABLE_STATUSES.has(status) + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const passiveStatusLabel = canShowReviewStatus && prDisplay ? getReviewStatusTooltip(prDisplay) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index ac01350de30..1063c5145e5 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -76,6 +76,7 @@ type Props = { onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void onOpenChange?: (open: boolean) => void + onLifecycleComplete?: () => void } const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -319,7 +320,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ selectedWorktrees, onContextMenuSelect, onAssignWorkspaceStatus, - onOpenChange + onOpenChange, + onLifecycleComplete }: Props) { const defaultSelectedWorktrees = useMemo(() => [worktree], [worktree]) const effectiveSelectedWorktrees = selectedWorktrees ?? defaultSelectedWorktrees @@ -345,6 +347,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ effectiveSelectedWorktrees ) const [createGroupDialogOpen, setCreateGroupDialogOpen] = useState(false) + const createGroupDialogActiveRef = useRef(false) const [parentPicker, setParentPicker] = useState<{ childWorktreeId: string anchorElement: HTMLElement @@ -356,6 +359,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ } | null>(null) const parentPickerFallbackTimerRef = useRef(null) const parentPickerUnmountTimerRef = useRef(null) + const lifecycleStartedRef = useRef(false) const isDeleting = deleteState?.isDeleting ?? false const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() @@ -500,6 +504,33 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ [onOpenChange] ) + useEffect(() => { + if (!onLifecycleComplete) { + return + } + if (menuOpen) { + lifecycleStartedRef.current = true + } + if ( + !lifecycleStartedRef.current || + menuOpen || + createGroupDialogOpen || + createGroupDialogActiveRef.current || + parentPicker !== null || + pendingParentPickerRef.current !== null + ) { + return + } + const timer = window.setTimeout(() => { + if (createGroupDialogActiveRef.current || pendingParentPickerRef.current !== null) { + return + } + lifecycleStartedRef.current = false + onLifecycleComplete?.() + }, 0) + return () => window.clearTimeout(timer) + }, [createGroupDialogOpen, menuOpen, onLifecycleComplete, parentPicker]) + useEffect(() => { const closeMenu = (): void => setMenuOpenState(false) window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) @@ -534,9 +565,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ if (!repo) { return } + createGroupDialogActiveRef.current = true setCreateGroupDialogOpen(true) }, [repo]) + const handleCreateGroupDialogOpenChange = useCallback((open: boolean) => { + createGroupDialogActiveRef.current = open + setCreateGroupDialogOpen(open) + }, []) + const handleSubmitNewProjectGroup = useCallback( async (name: string) => { if (!repo) { @@ -1088,7 +1125,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ )} initialName={repo ? `${repo.displayName} group` : ''} confirmLabel="Create" - onOpenChange={setCreateGroupDialogOpen} + onOpenChange={handleCreateGroupDialogOpenChange} onSubmit={handleSubmitNewProjectGroup} /> {/* Why: mounted only while open — one instance of this lives behind every diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 046795f5051..bed75283e38 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -8,7 +8,6 @@ import SetupScriptPromptCard from './SetupScriptPromptCard' import WorktreeList from './WorktreeList' import SidebarToolbar from './SidebarToolbar' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' -import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer' import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import { cn } from '@/lib/utils' import { FolderPlus, Loader2 } from 'lucide-react' @@ -23,6 +22,7 @@ const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog')) const WorktreeVisibilityDialog = lazyWithRetry(() => import('./WorktreeVisibilityDialog')) const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog')) const ForgetSshWorkspaceDialog = lazyWithRetry(() => import('./ForgetSshWorkspaceDialog')) +const AgentDashboardSidebarHost = lazyWithRetry(() => import('./AgentDashboardSidebarHost')) const MIN_WIDTH = 220 const MAX_WIDTH = 500 @@ -92,28 +92,6 @@ function Sidebar({ } }, [closeWorkspaceBoard, sidebarOpen, workspaceBoardRenderedOpen]) - const agentDashboardDrawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) - const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) - useEffect(() => { - if (!sidebarOpen && agentDashboardDrawerOpen) { - setAgentDashboardDrawerOpen(false) - } - }, [agentDashboardDrawerOpen, setAgentDashboardDrawerOpen, sidebarOpen]) - // Why: both companion boards expand into the same space beside the sidebar, - // so the most recently opened one dismisses the other. - useEffect(() => { - if (agentDashboardDrawerOpen) { - closeWorkspaceBoard() - } - }, [agentDashboardDrawerOpen, closeWorkspaceBoard]) - // Why: a transient drag preview is not the user opening the board, so it must - // not evict the dashboard — key on the opened state, not the rendered state. - useEffect(() => { - if (workspaceBoardOpen) { - setAgentDashboardDrawerOpen(false) - } - }, [setAgentDashboardDrawerOpen, workspaceBoardOpen]) - const { containerRef, onResizeStart, isResizing } = useSidebarResize({ isOpen: sidebarOpen, width: sidebarWidth, @@ -217,11 +195,16 @@ function Sidebar({ onMenuOpenChange={setWorkspaceBoardMenuOpen} /> ) : null} - {sidebarOpen && settings?.experimentalAgentDashboardPopout === true ? ( - + {settings?.experimentalAgentDashboardPopout === true ? ( + + + ) : null} ) diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index d32192d36a5..2c4324938cd 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -61,6 +61,7 @@ function makeEntry(overrides: Partial & { paneKey: string }): terminalTitle: overrides.terminalTitle, stateHistory: overrides.stateHistory ?? [], interrupted: overrides.interrupted, + sessionBoundary: overrides.sessionBoundary, restoredUnconfirmed: overrides.restoredUnconfirmed } } @@ -177,6 +178,23 @@ describe('resolveAttention', () => { expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE) }) + it('treats a session boundary as idle unless it displaced a real completion', () => { + const boundary = makeEntry({ + paneKey: 't:1', + state: 'done', + sessionBoundary: true, + stateStartedAt: NOW - 1_000, + updatedAt: NOW - 500 + }) + expect(resolveAttention([hookPane(boundary)], NOW)).toEqual(IDLE) + + boundary.stateHistory = [makeHistory('done', NOW - 90_000)] + expect(resolveAttention([hookPane(boundary)], NOW)).toEqual({ + cls: 2, + attentionTimestamp: NOW - 90_000 + }) + }) + it('classifies a working pane with prior done as Class 3 with the prior timestamp', () => { const entry = makeEntry({ paneKey: 't:1', diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 6c102a41405..760fd9d16ca 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -98,6 +98,21 @@ export function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]): return max > 0 ? max : null } +function mostRecentCompletedTurnInHistory(history: AgentStateHistoryEntry[]): number | null { + let max = 0 + for (const entry of history) { + if ( + entry.state === 'done' && + entry.interrupted !== true && + Number.isFinite(entry.startedAt) && + entry.startedAt > max + ) { + max = entry.startedAt + } + } + return max > 0 ? max : null +} + /** * One pane's contribution to a worktree's attention class. Fresh hook entries win; hookless * panes fall back to the title heuristic (design doc Edge case 9). Authority is per-pane, not per-worktree. @@ -142,7 +157,16 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten continue } cls = 2 - ts = entry.stateStartedAt + if (entry.sessionBoundary) { + // Why: idle connect is not new attention; only preserve a real completion it displaced. + const completedAt = mostRecentCompletedTurnInHistory(entry.stateHistory) + if (completedAt === null) { + continue + } + ts = completedAt + } else { + ts = entry.stateStartedAt + } } else { // working cls = 3 diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx index 1fd3099323d..42f6145b1f3 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx @@ -33,6 +33,9 @@ function hasSuccessfulDoneTransition( previousEntry && previousEntry.state !== 'done' && entry.state === 'done' && + // Why: a session-boundary done is an idle connect (STA-3386), not a value moment — + // a stale working row + resume would otherwise nag on launch. + entry.sessionBoundary !== true && !entry.interrupted && hasMeaningfulPrompt(entry) ) { diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index fe0f5e1a297..c4690a1c79a 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -1184,12 +1184,9 @@ export function ResourceUsageStatusSegment({
- {resourceManagerTooltipLines.map((line, index) => ( -
- {line} + {resourceManagerTooltipLines.map((line) => ( +
+ {line.text}
))}
diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 70e500ad09a..fc417d679f1 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -18,6 +18,11 @@ import { } from '../../../../shared/execution-host' import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-environments' import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow' +import { + connectedHostCountLabel, + connectingHostsLabel, + workspaceSyncProblemLabel +} from './ssh-status-segment-copy' import { SshTargetStatusRow } from './SshTargetStatusRow' import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' import { connectRuntimeEnvironmentAndRecordStatus } from './runtime-environment-explicit-connect' @@ -59,10 +64,6 @@ function overallDotColor( } } -function connectedHostCountLabel(count: number): string { - return `${count} ${count === 1 ? 'host' : 'hosts'}` -} - function sshStatusForOverall(status: SshConnectionStatus): HostStatus { if (status === 'connected') { return 'connected' @@ -286,9 +287,7 @@ export function SshStatusSegment({ (t) => t.syncStatus?.phase === 'conflict' || t.syncStatus?.phase === 'error' ) const syncProblemLabel = syncProblem - ? syncProblem.syncStatus?.phase === 'conflict' - ? 'Workspace conflict' - : 'Workspace sync error' + ? workspaceSyncProblemLabel(syncProblem.syncStatus?.phase) : null return ( {syncProblemLabel ?? - (anyConnecting ? 'Connecting…' : connectedHostCountLabel(connectedHostCount))} + (anyConnecting + ? connectingHostsLabel() + : connectedHostCountLabel(connectedHostCount))} )} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 9d7534e8c3b..1b3075e305b 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -2369,7 +2369,11 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele className="relative inline-flex size-5 cursor-pointer items-center justify-center rounded border border-border bg-secondary text-secondary-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground" aria-label={ showFloatingWorkspaceAttentionDot - ? `${floatingTerminalActionLabel}, new activity` + ? translate( + 'auto.components.status.bar.StatusBar.floatingTerminalNewActivity', + '{{label}}, new activity', + { label: floatingTerminalActionLabel } + ) : floatingTerminalActionLabel } onClick={() => { diff --git a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts index 42b42d492b6..7f6d1eefa3d 100644 --- a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts +++ b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts @@ -1,4 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +// Mirrors resource-memory-metric-copy.test.ts: assert the English source copy +// through the catalog fallback, with placeholders filled the way i18next would. +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (match, name: string) => String(options?.[name] ?? match)) +})) + import { formatTerminalSessionCount, getResourceManagerAriaLabel, @@ -19,8 +27,12 @@ describe('resource manager terminal copy', () => { spaceScanReady: false }) ).toEqual([ - 'Resource Manager - 512 MB · Σ RSS - 2 terminal sessions', - 'Terminal sessions are grouped by workspace.' + { + id: 'summary', + text: 'Resource Manager - 512 MB · Σ RSS - 2 terminal sessions', + emphasized: false + }, + { id: 'sessions-hint', text: 'Terminal sessions are grouped by workspace.', emphasized: false } ]) }) @@ -32,12 +44,45 @@ describe('resource manager terminal copy', () => { spaceScanReady: true }) ).toEqual([ - 'Resource Manager - memory unavailable - 0 terminal sessions', - 'Space scan ready', - 'No terminal sessions yet.' + { + id: 'summary', + text: 'Resource Manager - memory unavailable - 0 terminal sessions', + emphasized: false + }, + { id: 'space-scan', text: 'Space scan ready', emphasized: true }, + { id: 'sessions-hint', text: 'No terminal sessions yet.', emphasized: false } ]) }) + // Why: the tooltip used to tint this row by matching its English text, so any + // translated build lost the tint. The flag is what the segment reads now. + it('flags the space-scan row instead of leaving callers to match its wording', () => { + const lines = getResourceManagerTooltipLines({ + memoryLabel: '512 MB', + sessionCount: 1, + spaceScanReady: true + }) + + expect(lines.filter((line) => line.emphasized).map((line) => line.text)).toEqual([ + 'Space scan ready' + ]) + }) + + // Why: the tooltip keys rows by id, so a repeated id would silently drop a row. + it('gives every tooltip row a unique id to key on', () => { + for (const spaceScanReady of [false, true]) { + for (const sessionCount of [0, 1, 4]) { + const ids = getResourceManagerTooltipLines({ + memoryLabel: '512 MB', + sessionCount, + spaceScanReady + }).map((line) => line.id) + + expect(new Set(ids).size).toBe(ids.length) + } + } + }) + it('keeps the trigger label descriptive for screen readers', () => { expect( getResourceManagerAriaLabel({ diff --git a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts index 69b5b7b6193..0d082654199 100644 --- a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts +++ b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts @@ -1,30 +1,86 @@ +import { translate } from '@/i18n/i18n' + export function formatTerminalSessionCount(count: number): string { - return `${count} terminal session${count === 1 ? '' : 's'}` + return count === 1 + ? translate( + 'auto.components.status.bar.resource.manager.terminal.copy.terminalSessionCount_one', + '{{count}} terminal session', + { count } + ) + : translate( + 'auto.components.status.bar.resource.manager.terminal.copy.terminalSessionCount_other', + '{{count}} terminal sessions', + { count } + ) +} + +function spaceScanReadyLabel(): string { + return translate( + 'auto.components.status.bar.resource.manager.terminal.copy.spaceScanReady', + 'Space scan ready' + ) +} + +/** + * `emphasized` marks the space-scan row the tooltip tints. The segment used to + * recognize that row by comparing it against the English text, which stops + * matching the moment the copy comes from the catalog. + * + * `id` names the row's role. Each role appears at most once per tooltip, so it is + * a unique React key that survives locale switches and memory/count updates — + * unlike the translated text, which locales are free to duplicate across rows. + */ +export type ResourceManagerTooltipLine = { + id: 'summary' | 'space-scan' | 'sessions-hint' + text: string + emphasized: boolean } export function getResourceManagerTooltipLines(args: { memoryLabel: string sessionCount: number spaceScanReady: boolean -}): string[] { +}): ResourceManagerTooltipLine[] { const rawMemoryLabel = args.memoryLabel.trim() const memoryLabel = rawMemoryLabel === '' || rawMemoryLabel === '-' || rawMemoryLabel === '—' - ? 'memory unavailable' + ? translate( + 'auto.components.status.bar.resource.manager.terminal.copy.memoryUnavailable', + 'memory unavailable' + ) : rawMemoryLabel - const lines = [ - `Resource Manager - ${memoryLabel} - ${formatTerminalSessionCount(args.sessionCount)}` + // Why: whole lines are single keys — locales reorder the summary and repunctuate + // its separators, so it can't be concatenated from translated fragments here. + const lines: ResourceManagerTooltipLine[] = [ + { + id: 'summary', + text: translate( + 'auto.components.status.bar.resource.manager.terminal.copy.tooltipSummary', + 'Resource Manager - {{memory}} - {{sessions}}', + { memory: memoryLabel, sessions: formatTerminalSessionCount(args.sessionCount) } + ), + emphasized: false + } ] if (args.spaceScanReady) { - lines.push('Space scan ready') + lines.push({ id: 'space-scan', text: spaceScanReadyLabel(), emphasized: true }) } - if (args.sessionCount > 0) { - lines.push('Terminal sessions are grouped by workspace.') - } else { - lines.push('No terminal sessions yet.') - } + lines.push({ + id: 'sessions-hint', + text: + args.sessionCount > 0 + ? translate( + 'auto.components.status.bar.resource.manager.terminal.copy.sessionsGroupedByWorkspace', + 'Terminal sessions are grouped by workspace.' + ) + : translate( + 'auto.components.status.bar.resource.manager.terminal.copy.noTerminalSessions', + 'No terminal sessions yet.' + ), + emphasized: false + }) return lines } @@ -33,11 +89,19 @@ export function getResourceManagerAriaLabel(args: { sessionCount: number spaceScanReady: boolean }): string { - const parts = ['Resource Manager', formatTerminalSessionCount(args.sessionCount)] + const sessions = formatTerminalSessionCount(args.sessionCount) if (args.spaceScanReady) { - parts.push('Space scan ready') + return translate( + 'auto.components.status.bar.resource.manager.terminal.copy.ariaLabelWithSpaceScan', + 'Resource Manager, {{sessions}}, {{spaceScan}}', + { sessions, spaceScan: spaceScanReadyLabel() } + ) } - return parts.join(', ') + return translate( + 'auto.components.status.bar.resource.manager.terminal.copy.ariaLabel', + 'Resource Manager, {{sessions}}', + { sessions } + ) } diff --git a/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts b/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts new file mode 100644 index 00000000000..3cd3957872f --- /dev/null +++ b/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (match, name: string) => String(options?.[name] ?? match)) +})) + +import { + connectedHostCountLabel, + connectingHostsLabel, + workspaceSyncProblemLabel +} from './ssh-status-segment-copy' + +describe('ssh status segment copy', () => { + it('keeps the host noun singular for a lone connected host', () => { + expect(connectedHostCountLabel(0)).toBe('0 hosts') + expect(connectedHostCountLabel(1)).toBe('1 host') + expect(connectedHostCountLabel(4)).toBe('4 hosts') + }) + + it('distinguishes a sync conflict from a sync failure', () => { + expect(connectingHostsLabel()).toBe('Connecting…') + expect(workspaceSyncProblemLabel('conflict')).toBe('Workspace conflict') + expect(workspaceSyncProblemLabel('error')).toBe('Workspace sync error') + }) +}) diff --git a/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts b/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts new file mode 100644 index 00000000000..cf4cd7e3552 --- /dev/null +++ b/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts @@ -0,0 +1,31 @@ +import { translate } from '@/i18n/i18n' + +export function connectedHostCountLabel(count: number): string { + return count === 1 + ? translate( + 'auto.components.status.bar.SshStatusSegment.connectedHostCount_one', + '{{count}} host', + { count } + ) + : translate( + 'auto.components.status.bar.SshStatusSegment.connectedHostCount_other', + '{{count}} hosts', + { count } + ) +} + +export function connectingHostsLabel(): string { + return translate('auto.components.status.bar.SshStatusSegment.connecting', 'Connecting…') +} + +export function workspaceSyncProblemLabel(phase: string | undefined): string { + return phase === 'conflict' + ? translate( + 'auto.components.status.bar.SshStatusSegment.workspaceConflict', + 'Workspace conflict' + ) + : translate( + 'auto.components.status.bar.SshStatusSegment.workspaceSyncError', + 'Workspace sync error' + ) +} diff --git a/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx b/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx new file mode 100644 index 00000000000..96da506d9e9 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx @@ -0,0 +1,183 @@ +// @vitest-environment happy-dom +/** + * The Resource Manager tooltip and the SSH segment's host count were built from + * bare English literals inside helper functions, so they stayed English while + * every label around them translated. The coverage audit cannot see values + * returned from helpers, so only a runtime assertion against the real catalog + * keeps them honest — same reasoning as + * `src/renderer/src/i18n/settings-status-label-localization.test.ts`. + */ +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import { i18n } from '@/i18n/i18n' +import { + formatTerminalSessionCount, + getResourceManagerAriaLabel, + getResourceManagerTooltipLines +} from './resource-manager-terminal-copy' +import { SshStatusSegment } from './SshStatusSegment' + +type StoreState = { + sshConnectionStates: Map + sshTargetLabels: Map + remoteWorkspaceSyncStatusByTargetId: Record +} + +let storeState: StoreState = { + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + remoteWorkspaceSyncStatusByTargetId: {} +} + +vi.mock('../../store', () => { + const state = (): Record => ({ + ...storeState, + settings: null, + runtimeEnvironments: [], + runtimeStatusByEnvironmentId: new Map(), + setRuntimeEnvironmentStatus: vi.fn(), + hydrateRuntimeEnvironmentStatuses: vi.fn(), + setActiveView: vi.fn(), + openSettingsTarget: vi.fn(), + recordFeatureInteraction: vi.fn(), + fetchRuntimeEnvironmentRepos: vi.fn(), + fetchWorktrees: vi.fn(), + fetchWorktreeLineage: vi.fn() + }) + const useAppStore = (selector: (value: Record) => unknown): unknown => + selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +function setSshTargets( + entries: { id: string; label: string; status: SshConnectionStatus; syncPhase?: string }[] +): void { + storeState = { + sshConnectionStates: new Map(entries.map((entry) => [entry.id, { status: entry.status }])), + sshTargetLabels: new Map(entries.map((entry) => [entry.id, entry.label])), + remoteWorkspaceSyncStatusByTargetId: Object.fromEntries( + entries.flatMap((entry) => (entry.syncPhase ? [[entry.id, { phase: entry.syncPhase }]] : [])) + ) + } +} + +function triggerText(): string { + return screen.getByRole('button').textContent ?? '' +} + +describe('status-bar copy under a non-English UI language', () => { + beforeAll(async () => { + await i18n.changeLanguage('ja') + }) + + afterEach(() => { + cleanup() + }) + + afterAll(async () => { + await i18n.changeLanguage('en') + }) + + it('translates both plural forms of the terminal session count', () => { + expect(formatTerminalSessionCount(1)).toBe('1 件のターミナルセッション') + expect(formatTerminalSessionCount(5)).toBe('5 件のターミナルセッション') + }) + + it('translates every Resource Manager tooltip line', () => { + expect( + getResourceManagerTooltipLines({ + memoryLabel: '512 MB', + sessionCount: 2, + spaceScanReady: true + }) + ).toEqual([ + { + id: 'summary', + text: 'リソースマネージャー - 512 MB - 2 件のターミナルセッション', + emphasized: false + }, + { id: 'space-scan', text: '容量スキャンの準備完了', emphasized: true }, + { + id: 'sessions-hint', + text: 'ターミナルセッションはワークスペースごとにグループ化されます。', + emphasized: false + } + ]) + }) + + // Why: the tint used to be selected by `line === 'Space scan ready'`, so it + // silently vanished for every non-English locale once the copy translated. + it('keeps the space-scan row flagged when its copy is no longer English', () => { + const lines = getResourceManagerTooltipLines({ + memoryLabel: '512 MB', + sessionCount: 2, + spaceScanReady: true + }) + + const emphasized = lines.filter((line) => line.emphasized) + expect(emphasized).toHaveLength(1) + expect(emphasized[0]?.text).toBe('容量スキャンの準備完了') + expect(emphasized[0]?.text).not.toBe('Space scan ready') + }) + + it('translates the memory-unavailable and empty-session tooltip lines', () => { + expect( + getResourceManagerTooltipLines({ memoryLabel: '—', sessionCount: 0, spaceScanReady: false }) + ).toEqual([ + { + id: 'summary', + text: 'リソースマネージャー - メモリ情報を取得できません - 0 件のターミナルセッション', + emphasized: false + }, + { id: 'sessions-hint', text: 'ターミナルセッションはまだありません。', emphasized: false } + ]) + }) + + it('translates the Resource Manager trigger label read by screen readers', () => { + expect(getResourceManagerAriaLabel({ sessionCount: 1, spaceScanReady: true })).toBe( + 'リソースマネージャー、1 件のターミナルセッション、容量スキャンの準備完了' + ) + expect(getResourceManagerAriaLabel({ sessionCount: 3, spaceScanReady: false })).toBe( + 'リソースマネージャー、3 件のターミナルセッション' + ) + }) + + it('translates the connected host count next to the translated aria label', () => { + setSshTargets([ + { id: 'ssh-1', label: 'builder', status: 'connected' }, + { id: 'ssh-2', label: 'openclaw', status: 'connected' } + ]) + render() + + expect(screen.getByRole('button').getAttribute('aria-label')).toBe('リモートホスト接続状態') + expect(triggerText()).toContain('2 台のホスト') + }) + + it('translates the singular host count', () => { + setSshTargets([{ id: 'ssh-1', label: 'builder', status: 'connected' }]) + render() + + expect(triggerText()).toContain('1 台のホスト') + }) + + it('translates the connecting and workspace-sync states', () => { + setSshTargets([{ id: 'ssh-1', label: 'builder', status: 'connecting' }]) + render() + expect(triggerText()).toContain('接続中…') + cleanup() + + setSshTargets([ + { id: 'ssh-1', label: 'builder', status: 'connected', syncPhase: 'conflict' }, + { id: 'ssh-2', label: 'openclaw', status: 'connected', syncPhase: 'error' } + ]) + render() + expect(triggerText()).toContain('ワークスペースの競合') + cleanup() + + setSshTargets([{ id: 'ssh-1', label: 'builder', status: 'connected', syncPhase: 'error' }]) + render() + expect(triggerText()).toContain('ワークスペースの同期エラー') + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx index 23dd2a59568..3135f8f5009 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TabEntryOption } from './tab-create-entry-action' import type { TabAgentLaunchOption } from './tab-agent-launch-options' +import { TooltipProvider } from '@/components/ui/tooltip' // Why: the real entry-action module pulls in runtime IPC + the app store; the // keyboard behavior under test only needs a controllable option list. @@ -37,7 +38,8 @@ let root: Root function mount(node: React.JSX.Element): void { act(() => { - root.render(node) + // Result rows carry a path tooltip, which Radix requires a provider for. + root.render({node}) }) } diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx new file mode 100644 index 00000000000..616b3caa0c6 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { ActiveOption } from './tab-create-entry-active-option' +import { EntryActionRow } from './TabBarCreateEntryRow' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const filePath = 'app/src/components/SecondaryNav.tsx' + +function makeFileOption(path: string): ActiveOption { + return { + kind: 'entry', + option: { + id: `existing-file:${path}`, + classification: { + kind: 'existing-file', + matchKind: 'fuzzy', + relativePath: path + } + } + } +} + +// The tooltip itself is asserted in tests/e2e/tab-create-entry-file-paths.spec.ts, +// where it actually opens; here we only need the row's own text layout. +function renderRow(option: ActiveOption): HTMLButtonElement { + act(() => { + root.render( + createElement( + TooltipProvider, + null, + createElement(EntryActionRow, { + id: 'file-result', + onClick: vi.fn(), + option, + selected: false + }) + ) + ) + }) + + const button = container.querySelector('button') + if (!button) { + throw new Error('row did not render a button') + } + return button +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('EntryActionRow', () => { + it('puts the filename before the truncated parent path', () => { + const text = renderRow(makeFileOption(filePath)).textContent ?? '' + + expect(text.indexOf('SecondaryNav.tsx')).toBeLessThan(text.indexOf('app/src/components/')) + }) + + it('does not duplicate the root separator for absolute root-level files', () => { + const text = renderRow(makeFileOption('/foo')).textContent ?? '' + + expect(text).toContain('foo/') + expect(text).not.toContain('foo//') + }) + + it('keeps Windows separators intact', () => { + const text = renderRow(makeFileOption('C:\\repo\\src\\SecondaryNav.tsx')).textContent ?? '' + + expect(text.indexOf('SecondaryNav.tsx')).toBeLessThan(text.indexOf('C:\\repo\\src\\')) + }) + + it('renders a bare filename without a directory fragment', () => { + expect(renderRow(makeFileOption('README.md')).textContent).toContain('README.md') + expect(container.textContent).not.toContain('/') + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index 7aa57731c0e..1f1e27d1fd5 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -2,6 +2,7 @@ import React from 'react' import { FilePlus, FileText, Globe, Loader2, Smartphone, TerminalSquare } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' +import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' import { translate } from '@/i18n/i18n' import type { ActiveOption } from './tab-create-entry-active-option' @@ -41,7 +42,7 @@ export function EntryActionRow({ }): React.JSX.Element { const presentation = getActionPresentation(option) - return ( + const row = ( ) + + // Only the filename-first rows hide information. Every other row already shows + // its detail in full, and STYLEGUIDE.md:162 rules out labelling those. + if (!presentation.prioritizeFilename) { + return row + } + + return {row} +} + +function FilenameFirstPath({ path }: { path: string }): React.JSX.Element { + const { directory, filename } = splitTrailingSegment(path) + + return ( + + {/* shrink-0 + max-w-full: the directory gives up all of its width before + the filename loses a character. */} + {filename} + {directory ? ( + {directory} + ) : null} + + ) } function getActionPresentation(option: ActiveOption): { detail: string icon: React.ReactNode label: string + prioritizeFilename?: boolean showDetail: boolean } { if (option.kind === 'menu') { @@ -122,6 +151,7 @@ function getActionPresentation(option: ActiveOption): { : classification.relativePath, icon: