Merge remote-tracking branch 'origin/main' into brennanb2025/codex-sessions-visibility-12480

This commit is contained in:
Brennan Benson
2026-08-07 13:57:23 -07:00
792 changed files with 54937 additions and 5125 deletions
+6
View File
@@ -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
+1 -6
View File
@@ -150,10 +150,5 @@
}
}
],
"ignorePatterns": [
"**/node_modules",
"**/dist",
"**/out",
"tests/e2e/.cross-version-checkouts"
]
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
}
@@ -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",
+145 -24
View File
@@ -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."
},
+11 -2
View File
@@ -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 && <JSX/>` 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
@@ -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 <span>{label ?? (connecting ? 'Connecting…' : 'Idle')}</span>
}`
)
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 <div>{show && <button aria-label="Retry the sync" />}</div>
}`
)
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 <span>{phase === 'workspace conflict' ? phase : null}</span>
}`
)
expect(reports).toEqual([])
})
})
@@ -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')
})
+105 -35
View File
@@ -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 {
@@ -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)
})
@@ -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')
+10 -12
View File
@@ -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)
@@ -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)}`)
}
+4
View File
@@ -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"
],
+1
View File
@@ -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",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 15m">
<title>downloads: 15m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 16m">
<title>downloads: 16m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">15m</text>
<text x="90" y="14">15m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">16m</text>
<text x="90" y="14">16m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+2 -2
View File
@@ -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",
+6 -3
View File
@@ -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
})
}
+2 -4
View File
@@ -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}
/>
@@ -4986,6 +4986,7 @@ export default function SessionScreen() {
<MobileTerminalLiveInputStatus
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
</Pressable>
<MobileTerminalInputActions
+2
View File
@@ -165,6 +165,8 @@ type RepoSummary = {
kind?: 'git' | 'folder'
connectionId?: string | null
issueSourcePreference?: IssueSourcePreference
/** Fork parent resolved by the host; drives upstream Project row matching. */
upstream?: { owner: string; repo: string; host?: string } | null
}
type IssueSourcePreference = 'upstream' | 'origin' | 'auto'
+89 -97
View File
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import { QrCode, Settings, ChevronRight, Terminal, Plus, ListTodo } from 'lucide-react-native'
import { QrCode, Settings, ChevronRight, Terminal, ListTodo } from 'lucide-react-native'
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
import {
type AccountsSnapshot,
@@ -15,7 +15,8 @@ import {
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { loadHostCatalog } from '../src/transport/host-store'
import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection'
import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
@@ -26,16 +27,21 @@ import { createHostConnectRefetchGate } from '../src/transport/host-connect-refe
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import {
resolveHomeHostConnectionState,
selectHomeAutoConnectHostIds
} from '../src/transport/home-host-auto-connect'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { MobileHostCard } from '../src/components/MobileHostCard'
import { MobileHomeQuickActions } from '../src/components/MobileHomeQuickActions'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { ActionSheetModal } from '../src/components/ActionSheetModal'
import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions'
@@ -66,15 +72,8 @@ import {
type HomeResumeCard
} from '../src/worktree/home-resume-card'
import { hostRouteWithNotice } from '../src/host-route-notice'
function endpointLabel(endpoint: string): string {
try {
const url = new URL(endpoint)
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
} catch {
return endpoint
}
}
import { hostNewWorktreeRoute } from '../src/host-route-action-state'
import { hostEndpointLabel } from '../src/transport/host-endpoint-label'
type HomeTaskSettings = {
visibleTaskProviders?: unknown
@@ -224,9 +223,9 @@ export default function HomeScreen() {
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [hosts, setHosts] = useState<HostProfile[]>([])
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
@@ -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() {
</Pressable>
</View>
{hosts.length === 0 ? (
{hostCatalog.length === 0 ? (
/* ─── Empty state: onboarding ─── */
<View
style={[
@@ -694,7 +710,7 @@ export default function HomeScreen() {
) : (
/* ─── Populated state ─── */
<FlatList
data={sortedHosts}
data={sortedHostCatalog}
keyExtractor={(h) => 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 (
<MobileHostCard
host={item}
credentialStatus={item.credentialStatus}
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeInfo={worktreeInfo[item.id]}
onPress={() => 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 ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.quickAction,
!primaryConnectedHost && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
if (primaryConnectedHost) {
router.push(`/h/${primaryConnectedHost.id}?action=newWorktree`)
}
}}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
<MobileHomeQuickActions
connectedHosts={connectedHosts}
onPairDesktop={() => router.push('/pair-scan')}
onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))}
/>
{/* ─── Account usage ─── */}
{accountsHosts.length > 0 ? (
@@ -917,10 +934,16 @@ export default function HomeScreen() {
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.name}
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
message={actionTarget ? hostEndpointLabel(actionTarget.endpoint) : undefined}
actions={getHostListActionSheetActions({
host: actionTarget,
state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected',
state: actionTarget
? resolveHomeHostConnectionState(
actionTarget.id,
hostStates[actionTarget.id],
autoConnectHostIds
)
: 'disconnected',
hasEverConnected: actionTarget
? (hostLastConnected[actionTarget.id] ?? null) != null
: false,
@@ -928,7 +951,7 @@ export default function HomeScreen() {
onReconnect: (hostId) => 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
+2 -2
View File
@@ -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]
+3 -12
View File
@@ -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<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const [routeFocused, setRouteFocused] = useState(false)
useFocusEffect(
useCallback(() => {
setRouteFocused(true)
return () => setRouteFocused(false)
}, [])
)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return false
@@ -45,7 +45,7 @@ describe('mobile accounts route', () => {
navigateToHostStackRoute(
harness.navigation,
{ push },
{ push, replace: vi.fn() },
'host/one',
mobileAccountsRouteTarget('host/one')
)
@@ -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()
})
})
@@ -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<string, number>()
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<string | null>(null)
const pendingHostIdRef = useRef<string | null>(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 (
<>
<Text style={styles.sectionHeading}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
accessibilityRole="button"
style={({ pressed }) => [styles.quickAction, pressed && styles.quickActionPressed]}
onPress={props.onPairDesktop}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: !canCreateWorkspace }}
disabled={!canCreateWorkspace}
style={({ pressed }) => [
styles.quickAction,
!canCreateWorkspace && styles.quickActionDisabled,
pressed && styles.quickActionPressed
]}
onPress={handleCreateWorkspace}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
<PickerModal
visible={hostPickerVisible}
title="Create Workspace On"
options={hostPickerOptions(props.connectedHosts)}
selected=""
onSelect={handleHostSelect}
onClose={() => 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'
}
})
@@ -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"
)
})
})
+30 -3
View File
@@ -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<string[]> {
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')
})
})
+122 -42
View File
@@ -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 (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={props.onPress}
onLongPress={props.onLongPress}
delayLongPress={400}
>
<View style={styles.icon}>
<Monitor size={20} color={connected ? colors.textPrimary : colors.textSecondary} />
</View>
<View style={styles.main}>
<Text
style={[styles.name, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{props.host.name}
</Text>
<View style={styles.meta}>
<StatusDot state={props.state} verdict={props.verdict} />
<Text style={[styles.metaText, isError && { color: colors.statusRed }]} numberOfLines={1}>
{verdictDisplayLabel(props.verdict)}
{connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
</Text>
<View style={styles.card}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={({ pressed }) => [styles.cardMain, pressed && styles.cardPressed]}
onPress={props.onPress}
onLongPress={props.onLongPress}
delayLongPress={400}
>
<View style={styles.icon}>
<Monitor size={20} color={connected ? colors.textPrimary : colors.textSecondary} />
</View>
{connected && worktreeSummary ? (
<Text style={styles.worktreeMetaText} numberOfLines={1}>
{worktreeSummary}
<View style={styles.main}>
<Text
style={[styles.name, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{props.host.name}
</Text>
) : null}
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
Update desktop Orca and sign in to connect from anywhere
</Text>
) : null}
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.meta}>
<StatusDot state={props.state} verdict={statusVerdict} />
<Text
style={[
styles.metaText,
isError && { color: colors.statusRed },
credentialUnavailable && { color: colors.statusAmber }
]}
numberOfLines={1}
>
{statusLabel}
{connectionPathLabel ? ` · ${connectionPathLabel}` : ''}
</Text>
</View>
{connected && worktreeSummary ? (
<Text style={styles.worktreeMetaText} numberOfLines={1}>
{worktreeSummary}
</Text>
) : null}
{discoveryHint ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
{discoveryHint}
</Text>
) : null}
{credentialHint ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
{credentialHint}
</Text>
) : null}
</View>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Actions for ${props.host.name}`}
hitSlop={8}
style={({ pressed }) => [styles.actionButton, pressed && styles.actionButtonPressed]}
onPress={props.onOpenActions}
>
<MoreVertical size={18} color={colors.textSecondary} />
</Pressable>
</View>
)
}
@@ -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
}
})
@@ -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 }
])
})
})
+6 -1
View File
@@ -20,6 +20,7 @@ type Props<T extends string = string> = {
onSelect: (value: T) => void
onLongSelect?: (value: T) => void
onClose: () => void
onAfterClose?: () => void
zIndex?: number
}
@@ -36,10 +37,11 @@ export function PickerModal<T extends string = string>({
onSelect,
onLongSelect,
onClose,
onAfterClose,
zIndex
}: Props<T>) {
return (
<BottomDrawer visible={visible} onClose={onClose} zIndex={zIndex}>
<BottomDrawer visible={visible} onClose={onClose} onAfterClose={onAfterClose} zIndex={zIndex}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
</View>
@@ -72,6 +74,9 @@ function PickerModalContent<T extends string = string>({
<View key={opt.value}>
{i > 0 && <View style={styles.separator} />}
<Pressable
accessible
accessibilityRole="button"
accessibilityState={{ disabled: Boolean(opt.disabled), selected: isSelected }}
disabled={opt.disabled}
style={({ pressed }) => [
styles.row,
+12
View File
@@ -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 = {
'': {
+2 -1
View File
@@ -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 {
@@ -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',
+13
View File
@@ -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 {
@@ -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,
@@ -35,8 +35,14 @@ export type HostStackRootNavigation = {
export type HostStackHostRoute = `/h/${string}`
export type HostStackRouteHref = Readonly<{
pathname: `/h/${string}`
params: Readonly<Record<string, string>>
}>
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()
@@ -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()
@@ -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()
})
})
@@ -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<string>
credentialStatusByHostId?: ReadonlyMap<string, HostCredentialStatus>
}
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 }
: {})
}
}
@@ -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
+1 -1
View File
@@ -187,7 +187,7 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac
disabled={!canAdvance}
>
<Text style={[styles.nextText, !canAdvance && styles.nextTextDisabled]}>
{isLast ? 'Send answer' : 'Next'}
{isLast ? 'Submit' : 'Next'}
</Text>
</Pressable>
</View>
@@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
nativeChatStreamLive: tick.streamLive ?? false,
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
chatPending: [],
chatImagePreviewsByMessageId: {},
chatComposerText: '',
setChatComposerText: vi.fn()
} as unknown as MobileNativeChatController
@@ -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')}
@@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({
vi.mock('./MobileNativeChatComposer', async () => {
const React = await import('react')
return {
MobileNativeChatComposer: (props: { onSend: (text: string) => Promise<boolean> }) =>
MobileNativeChatComposer: (props: {
onSend: (text: string) => Promise<boolean>
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()
}
})
})
+20 -10
View File
@@ -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<string, string[]>
/** 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 (
<View style={[styles.root, { paddingBottom: bottomPad }]}>
@@ -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 (
<View style={styles.status}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<Text style={styles.detail} numberOfLines={1}>
<Text style={styles.detail} numberOfLines={1} ellipsizeMode="head">
{detail}
</Text>
</View>
@@ -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<typeof import('expo-image-picker').requestMediaLibraryPermissionsAsync>
>
const denied = { granted: false } as typeof granted
async function collectImages(
images: AsyncIterable<PickedMobileImage>
): Promise<PickedMobileImage[]> {
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', {
@@ -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<PickedMobileImage | null> {
): AsyncGenerator<PickedMobileImage> {
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<PickedMobileImage | null> {
): AsyncGenerator<PickedMobileImage> {
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<PickedMobileImage> {
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<PickedMobileImage | null> {
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<PickedMobileImage> {
return pickMobileImagesWithMode(source, true, deps)
}
@@ -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<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
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<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** 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<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** 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<MobileNativeChatSendOutcome>
/** 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
}
@@ -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 },
@@ -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'] }
})
})
})
@@ -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<string, Record<string, string[]>>,
sessionKey: string,
landed: readonly LandedImagePreviewEcho[]
): Record<string, Record<string, string[]>> {
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<string, Record<string, string[]>>,
sessionKey: string,
messages: readonly NativeChatMessage[]
): Record<string, Record<string, string[]>> {
const sessionPreviews = previous[sessionKey]
if (!sessionPreviews) {
return previous
}
const messageIndexById = new Map(messages.map((message, index) => [message.id, index]))
let nextSession: Record<string, string[]> | 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<string>()
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[]
@@ -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()
})
@@ -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 {
@@ -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<RpcClient, 'sendRequest'> & {
calls: { method: string; params: unknown }[]
} {
@@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
}
}
describe('uploadMobileNativeChatImage', () => {
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)
@@ -13,34 +13,67 @@ export type PendingNativeChatImage = {
readonly previewUri: string
}
export type UploadNativeChatImageDeps = {
export function appendPendingNativeChatImages(
current: readonly PendingNativeChatImage[],
uploaded: readonly Omit<PendingNativeChatImage, 'id'>[],
idCounter: { current: number }
): PendingNativeChatImage[] {
return [
...current,
...uploaded.map((image) => {
idCounter.current += 1
return { id: `img-${idCounter.current}`, ...image }
})
]
}
export type UploadNativeChatImagesDeps = {
readonly client: Pick<RpcClient, 'sendRequest'>
readonly getConnectionId: () => Promise<string | null>
// Injected so this module stays free of expo/react-native imports (unit-testable).
readonly pickImage: (source: MobileImageSource) => Promise<PickedMobileImage | null>
readonly pickImages: (
source: MobileImageSource
) =>
| Iterable<PickedMobileImage>
| AsyncIterable<PickedMobileImage>
| Promise<Iterable<PickedMobileImage> | AsyncIterable<PickedMobileImage>>
// 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<PendingNativeChatImage, 'id'>) => 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<Omit<PendingNativeChatImage, 'id'> | null> {
const picked = await pickImage(source)
if (!picked) {
return null
{
client,
getConnectionId,
pickImages,
onUploadStart,
onImageUploaded
}: UploadNativeChatImagesDeps
): Promise<Omit<PendingNativeChatImage, 'id'>[]> {
const picked = await pickImages(source)
const uploaded: Omit<PendingNativeChatImage, 'id'>[] = []
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 <Image> 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 <Image> renders both).
const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}`
return { path, previewUri }
return uploaded
}
@@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
describe('pasteMobileNativeChatImagePaths', () => {
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 () => {
@@ -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<string, MobileNativeChatPendingMessage[]>
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<string>
): 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
}
@@ -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.
@@ -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<string, string[]>
}): { 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 }
}
@@ -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()
@@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
composerText: '',
setComposerText: vi.fn(),
pending: [],
imagePreviewsByMessageId: {},
captureSendOrigin,
readSeededLaunchDraft: () => null,
readSeededLaunchDraftSeed: () => null,
@@ -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<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
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<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** 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<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** 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<MobileNativeChatSendOutcome>
/** 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,
@@ -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 })
})
})
@@ -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<string, string[]> = {}
// 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<SetStateAction<string>>
pending: MobileNativeChatPendingMessage[]
/** Phone-local previews rebound to the transcript message that replaced the
* optimistic echo, keyed by authoritative message id. */
imagePreviewsByMessageId: Record<string, string[]>
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<string, MobileNativeChatPendingMessage[]>
>({})
const [pendingWaitingForSession, setPendingWaitingForSession] = useState<
Record<string, MobileNativeChatPendingMessage[]>
>({})
const [imagePreviewsBySession, setImagePreviewsBySession] = useState<
Record<string, Record<string, string[]>>
>({})
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<string, number>()
@@ -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,
@@ -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<void> | 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<RpcResponse>((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<void> | null = null
await act(async () => {
attach = hook!.attachImage('library')
@@ -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<boolean>
}
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<PendingNativeChatImage, 'id'>[] = []
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)
}
}
}
},
[
@@ -15,14 +15,16 @@ const ASK = JSON.stringify({
function promptsFor(
status: Partial<AgentStatusEntry> | null,
messages: NativeChatMessage[] = []
messages: NativeChatMessage[] = [],
transcriptLoading = false
): ReturnType<typeof useMobileNativeChatPrompts> {
let captured: ReturnType<typeof useMobileNativeChatPrompts> | 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
@@ -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 {
@@ -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 () => {
@@ -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<NativeChatMessage[]>([])
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,
@@ -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<RpcClient, 'sendRequest'> {
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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
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<RpcClient['sendRequest']>().mockImplementation(async () => {
current = false
return success({ tabs: [] })
})
await expect(
revealMobileSourceControlSessionDiff({
...options(sendRequest),
isCurrent: () => current
})
).resolves.toBe('cancelled')
expect(sendRequest).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,143 @@
import type { RpcClient } from '../transport/rpc-client'
import { activateMobileSessionTab } from '../session/mobile-session-tab-activation'
type ActivationClient = Pick<RpcClient, 'sendRequest'>
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<MobileSourceControlSessionDiffRevealResult> {
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<SessionFileTabCandidate | null> {
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<boolean> {
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<string, unknown> {
return typeof value === 'object' && value !== null
}
async function waitForDelay(delayMs: number): Promise<void> {
if (delayMs === 0) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
}
@@ -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
@@ -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(
@@ -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.
+29 -31
View File
@@ -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'
)
@@ -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')
@@ -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<boolean>((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<boolean>((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<boolean> => {
payloads.push(payload)
if (payloads.length === 1) {
return new Promise<boolean>((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<boolean> =>
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)
})
})
@@ -1,5 +1,30 @@
type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise<boolean>
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<boolean> | 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<boolean> {
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<boolean>
handle: string,
payload: string,
sender: TerminalLiveMirrorSender
): Promise<boolean> {
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<boolean>((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
}
@@ -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<TTabType extends string>({
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
@@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
@@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}, [])
const sendQueuedMirrorPayload = useCallback(
(handle: string, payload: string): Promise<boolean> =>
sendLiveTerminalInputRef.current(handle, payload),
[sendLiveTerminalInputRef]
)
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
if (
@@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
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<TTabType extends string>({
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
sendLiveTerminalInputRef,
sendQueuedMirrorPayload,
waitForPendingLiveInputFlush
]
)
@@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
pendingLiveInputFlushRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}
}, [])
+178
View File
@@ -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<string, FakeClient>()
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<void>) | 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())
}
})
})
+106 -84
View File
@@ -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<void>
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<Map<string, number>>(new Map())
// Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start.
const primedHostsRef = useRef<Map<string, HostProfile>>(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<StoreEntry | null> => {
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<StableLogicalRpcClient> | 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()
}
@@ -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'
)
})
})
@@ -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')
}
@@ -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<string, MobileRelayHostOverlay>
tokenCache: Map<string, string>
readToken: (hostId: string) => Promise<string | null>
getRevision: () => number
}): Promise<HostListSnapshot> {
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 }
}
@@ -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])
})
})
@@ -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] : []
)
}
@@ -0,0 +1,9 @@
export function decrementPendingAcquisition(pending: Map<string, number>, 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
}
@@ -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<string, Set<(state: ConnectionState) => 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<string, Set<(state: ConnectionState) => 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<string, { client: RpcClient }>
): { hostId: string; client: RpcClient }[] {
return [...entries].map(([hostId, entry]) => ({ hostId, client: entry.client }))
}
export function primeHostProfiles(cache: Map<string, HostProfile>, 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<StableLogicalRpcClient> | 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()
}
@@ -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<void>(() => undefined))
@@ -155,13 +155,17 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
async function recordCleanupIntent(hostId: string): Promise<boolean> {
try {
await addPendingId(hostId)
await recordHostCredentialCleanupIntent(hostId)
return true
} catch {
return false
}
}
export async function recordHostCredentialCleanupIntent(hostId: string): Promise<void> {
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<void> {
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,
@@ -0,0 +1,21 @@
let nextRevision = 1
const revisionByHostId = new Map<string, number>()
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()
}
@@ -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<string | null> {
// 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<void> {
if (Platform.OS === 'web') {
await AsyncStorage.setItem(webTokenKey(hostId), token)
return
}
await writePairingKeychainItem(tokenKey(hostId), token)
}
export async function deleteHostDeviceToken(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
await AsyncStorage.removeItem(webTokenKey(hostId))
return
}
await deletePairingKeychainItem(tokenKey(hostId))
}
@@ -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()
@@ -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'
}
}
+10 -3
View File
@@ -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<HostProfile[]> | null = null
let inflight: Promise<HostListSnapshot> | null = null
let revision = 0
export function getHostListLoadRevision(): number {
return revision
}
export function shareHostListLoad(load: () => Promise<HostProfile[]>): Promise<HostProfile[]> {
export function shareHostListLoad(
load: () => Promise<HostListSnapshot>
): Promise<HostListSnapshot> {
if (inflight) {
return inflight
}
@@ -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<StoredHostProfile[] | null> {
return parseStoredHostProfiles(await AsyncStorage.getItem(STORAGE_KEY))
}
export async function readStoredHostProfilesForMutation(): Promise<StoredHostProfile[]> {
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<void> {
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
}
}
+328 -3
View File
@@ -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<void> {
const cleanup = scheduleCleanupMock.mock.calls.find(([id]) => id === hostId)?.[1]
expect(cleanup).toBeTypeOf('function')
return cleanup as (id: string) => Promise<void>
}
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<void>((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<string>((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<void> | 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<void>((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<void>((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) => {

Some files were not shown because too many files have changed in this diff Show More