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: