Merge remote-tracking branch 'origin/main' into brennanb2025/fix-10385-false-connected

# Conflicts:
#	mobile/app/index.tsx
#	mobile/src/transport/client-context.test.ts
#	mobile/src/transport/client-context.tsx
#	mobile/src/transport/mobile-endpoint-supervisor.ts
#	mobile/src/transport/mobile-relay-physical-client.ts
#	mobile/src/transport/pre-profile-pairing-coordinator.ts
#	mobile/src/transport/rpc-client.ts
#	mobile/src/transport/stable-logical-rpc-client.ts
This commit is contained in:
Brennan Benson
2026-08-04 20:07:45 -07:00
305 changed files with 19529 additions and 2428 deletions
+3 -2
View File
@@ -4435,7 +4435,7 @@
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, and installs no interval. Broader terminal interaction coverage remains on the pending reliability stack.",
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, installs no interval, and re-reads only once per explicit daemon-management kill/restart (which emits no pty:exit). Broader terminal interaction coverage remains on the pending reliability stack.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7002",
"https://github.com/stablyai/orca/pull/6858",
@@ -4464,7 +4464,8 @@
"spawn signals during a slow inventory read never overlap provider-wide scans and cause at most one required follow-up",
"unknown sessions that exit before reconciliation cancel their queued inventory read",
"unmount during a slow inventory read cannot schedule follow-up work",
"exit and out-of-order refresh races cannot resurrect stale sessions"
"exit and out-of-order refresh races cannot resurrect stale sessions",
"an explicit daemon-management invalidation performs exactly one inventory read, and none before readiness or after unmount"
]
},
{
+442
View File
@@ -0,0 +1,442 @@
# Mobile Relay UX — Investigation Findings & Fix Plan
Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over
the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes
desktop or relay-server code. All file references are in `mobile/` of this worktree.
## 1. Symptom → root-cause summary
| # | Symptom | Root cause (verified) |
|---|---------|----------------------|
| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` |
| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication |
| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible |
## 2. Verified end-to-end causal chains
### S1 — Resume dead-ends on the host index page
1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'`
(`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card
inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift
under the thumb.
2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting
`/h/[hostId]/session/[worktreeId]`.
3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves
the push to the host stack's **index route with no `hostId` param**. This exact failure mode is
documented twice in-repo ("cold Expo deep links resolve to index" —
`src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the
root cause named by PR #12001.
4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`.
`HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns
`state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**.
5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and
every fetch no-ops on `!client || connState !== 'connected'`
(`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are
static toolbar UI, so the page looks "real" but dead.
6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by
navigation history.
Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not
the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with*
a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than
it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration
(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`),
so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it.
**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through
the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`.
Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation
(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4),
catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9).
### S2 — grey blink when focusing a healthy relay host
1. Every focus of the host screen fires `notifyForeground()`
(`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps).
2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)`
(`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe
(`src/transport/mobile-endpoint-lifecycle.ts:62-64`).
3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls
`RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`).
4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session**
(`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns
unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink.
5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes
`'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot
(`src/components/StatusDot.tsx:11`), worktree queries blocked.
6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state
**stays 'disconnected'**: `migrateTo` only binds the new session's state after
`waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the
dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`)
fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial
delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`).
7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun.
Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live
client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`)
— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host.
Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the
network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The
asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a
connected direct client runs an activity probe that detects a half-open socket in ≤8s
(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground`
(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is
suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls
`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink
(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`).
Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber,
`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a
missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey
during the async client acquire (Keychain read) that home never shows.
### S3 — silent 5–10s relay-forced pairing
Pairing phase:
1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing`
(`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`).
2. The coordinator threads it **only to the direct candidate**
(`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate
(`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all
(`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution,
journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`.
3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its
12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE
handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write
(`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for
where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it.
4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director
resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped
at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of
silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the
director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay
request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`).
5. Pairing logs also never reach `connectionLogStore` (single producer:
`src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a
pairing that just failed.
Post-pairing phase:
6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/<id>`
(`app/pair-confirm.tsx:118-123`).
7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the
Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first**
(`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s`
(`src/transport/rpc-client.ts:126`) on a black-holed LAN.
8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live
progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor
start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`).
9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism
as S2) → green. Worst case with a director resolution failure and grace-credential retry:
~29–58s under the old session's labels.
10. The "Orca Relay" path label only renders once `state === 'connected'`
(`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only
after the wait ends, and `classifyConnection` has no relay-aware branch
(`src/transport/connection-health.ts:45-100`).
## 3. Anti-pattern sweep
### (a) Uncoordinated deep pushes into `/h` from outside the host stack
Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and
Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the
root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks
proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`).
Bare pushes remaining (host stack plausibly cold at each):
| Call site | Target | Cold scenario |
|---|---|---|
| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h/<id>/session/<wt>` or `/h/<id>` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener |
| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 |
| `app/index.tsx:835` (Account-usage card) | `/h/<id>/accounts` | Home is the root route |
| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes |
| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first |
Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination.
No `<Link>`, `navigationRef`, or `router.navigate` anywhere in `mobile/`.
Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit
host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight
(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts
(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount
can be destroyed by the gate cycle.
### (b) Surfaces that render grey 'disconnected' during expected transients
Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing
entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound
fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's
`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also
refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved
locally what every other surface gets wrong.
The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass)
**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold
start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical
client is not the cause — it already reports `'connecting'` synchronously by the time `connect()`
returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the
async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before
amber**.
Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via
`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled
(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for
`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner
(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty
state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all,
`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat
composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history /
diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log
screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick
Action gates all read `=== 'connected'`; voice settings goes fully inert
(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts
screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`).
Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on
focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` —
correct per billing, but state stays grey through the entire foreground re-dial rather than
flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the
pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths
(`:107,114,135`).
Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**:
`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect;
`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets;
`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent
lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` →
`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch
(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is
wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read
only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s
entry means an empty remount.
Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to
"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863`
and `tasks.tsx:10970`).
### (c) Invisible relay establishment phases
- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay
session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the
supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`).
- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states
(`src/transport/stable-logical-rpc-client.ts:182-223,267-299`).
- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`,
`mobile-relay-direct-upgrade-controller.ts:19`).
- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`.
- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`);
`classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch.
- Regression suite for connect-label stalls exists for the direct path only
(`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent.
## 4. Fix plan
Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire
protocol, so every old/new phone × old/new desktop pairing keeps working unless noted.
### F0 (S1, quick win) — land PR #12001 ✅ MERGED
Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check
against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and
auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR
branch. The PR routes Resume through the shared mount-then-replace mechanism
(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the
bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the
extracted module.
Backward compat: navigation-only, none.
Residuals tracked as F4/F7/F8/F9.
### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch)
Approach: split the nudge reasons that today all funnel into `setForeground(true)`:
- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either
no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short
budget) and only enter recovery on failure — mirroring the direct path's activity probe.
- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement**
instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path
(proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the
replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying
green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is
still `'connected'`, so the fallback suspend is required for convergence).
Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`),
`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend),
`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged
`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter.
Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing
`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already
accepts this). Half-open regression risk is covered by the fallback suspend.
Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot
stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the
background-suspend test unchanged.
### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch)
Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or
pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every
host screen with home's `?? 'connecting'`. Two companion changes in the same class:
- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving
the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button
currently drives the UI grey before amber.
- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the
cold-start gap (`client-context.tsx:87-153`) is amber too.
**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to
`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/
`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without
extending that predicate would silently disable the stale-list fallback and blank the list —
extend it to every not-connected state (or key it on "no live fetch has succeeded this mount").
Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`,
`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate),
`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead
of `null`).
Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates —
acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that
key on `'disconnected'` specifically.
Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence;
host screen test for the stale-list fallback under `'connecting'`.
### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch)
Approach: add an optional `onLog` to `connectMobileRelayForPairing`,
`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread
`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay:
resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated",
"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore`
under the resolved host id so the Connection Log screen has a record post-pairing.
Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`,
`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`.
Risk: none (additive, phone-local). Old desktops: unaffected — logging only.
Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`;
extend `pairing-relay-candidate.test.ts` for per-attempt lines.
### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes
Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`)
and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts`
once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted
follow-up). `orca://` deep links can follow later via a route-level guard.
Risk: notification cold-start ordering (push before root nav ready) — the mechanism already
tolerates that by waiting for state commits.
Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target.
### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo`
Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the
dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on
success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a
still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI:
show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let
`MobileHostCard`/`classifyConnection` render it while not yet connected.
Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`,
`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`.
Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the
forwarding strictly gated on suspended/disconnected.
Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases:
forwarded states during suspended dial, no forwarding during live-session replacement, failure
restores `'disconnected'`.
### F6 (S3, deeper) — happy-eyeballs relay start post-pairing
Approach: when a relay credential bundle exists and the direct dial has not authenticated within a
short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure;
first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to
the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay
sockets on healthy LANs.
Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a
phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so
the path is naturally guarded).
Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex
(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too
broadly.
Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a
relay socket; relay loser closed after direct wins.
### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce
Approach: (1) on Resume tap with the host connected, validate the target against the freshest
`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead.
(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s
(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`)
to the host index with a notice. (3) Use the validating reader in
`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse`
(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites.
Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss.
Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel
exemptions.
### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift
Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its
late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately
from the snapshot in a disabled state until the host connects.
Files: `app/index.tsx` footer.
Risk: none.
Tests: render test asserting footer order/height stability across `resumeWorktree` arrival.
### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack
Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending
spinner instead of replacing children, preserving in-flight nested navigation; keep the hard
replace only for the `blocked` verdict.
Files: `src/components/HostProtocolGate.tsx`.
Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an
overlay must still block interaction until resolved; verify child mount effects don't fire gated
RPCs pre-verdict before choosing overlay vs. current behavior.
Tests: gate test asserting no unmount across `statusPending` for an already-mounted host.
### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips
Approach: audit the §3(b) destructive-clear sites and make each preserve data across a
not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt
impact: git history blanking rows on the reconnect branch
(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo
cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on
error); the diff-review "ready-state preserved" branch that is dead code because it sits after the
early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe
(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration
(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount
(`app/h/[hostId]/index.tsx:129,330`).
Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts
rather than inventing new indicators. The in-repo reference pattern is
`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as
"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable`
preserves proven counts across a failed refresh, flagging only `staleCounts`.
Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger*
(suspend blips), so this is hardening, not the primary fix.
## 5. Backward compatibility (old/new phone × old/new desktop)
- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing
steps change. Old phones against any desktop are untouched (they don't have the code).
- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths
never activate; pairing keeps the existing `method_not_found` downgrade
(`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay
candidate is created).
- New phone + old desktop with relay: all paths use existing RPCs (`status.get`,
`pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses
the same resume-credential flow lease rotation already exercises against production desktops.
- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today.
## 6. Constants appendix (verified)
| Constant | Value | Where |
|---|---|---|
| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` |
| Direct handshake timeout | 5s | `rpc-client.ts:127` |
| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` |
| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` |
| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` |
| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` |
| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` |
| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` |
| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` |
| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` |
| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` |
| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` |
| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` |
+11 -7
View File
@@ -8,7 +8,8 @@ import * as Linking from 'expo-linking'
import { colors } from '../src/theme/mobile-theme'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
import { loadHosts } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { MobileRelayPairingRecoveryBridge } from '../src/transport/mobile-relay-pairing-recovery-bridge'
@@ -34,6 +35,7 @@ Notifications.setNotificationHandler({
export default function RootLayout() {
const router = useRouter()
const openNotificationRoute = useOpenNotificationRoute()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
// Why: route `orca://pair?...` deep links to the confirm screen so
@@ -62,6 +64,7 @@ export default function RootLayout() {
return () => sub.remove()
}, [router])
// ─── Notification tap routing ───
// Why: iOS delivers local notification taps through expo-notifications,
// not Linking. Route both cold-start and warm-start responses to the host
// and worktree that scheduled the notification.
@@ -85,9 +88,9 @@ export default function RootLayout() {
}
}
async function getNavigationPath(data: unknown): Promise<string | null> {
async function getNavigationTarget(data: unknown) {
const hosts = await loadHosts().catch(() => null)
return getNotificationNavigationPath(data, {
return getNotificationNavigationTarget(data, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined
})
}
@@ -112,13 +115,13 @@ export default function RootLayout() {
}
}
const path = await getNavigationPath(response.notification.request.content.data)
const target = await getNavigationTarget(response.notification.request.content.data)
clearLastNotificationResponse()
if (disposed) {
return
}
if (path) {
router.push(path)
if (target) {
openNotificationRoute(target)
}
}
@@ -134,7 +137,8 @@ export default function RootLayout() {
disposed = true
sub.remove()
}
}, [router])
}, [openNotificationRoute])
// ─── End notification tap routing ───
// Why: hide the native splash only once the navigation Stack has been laid
// out — this is the earliest moment the user will see actual app content.
+33 -62
View File
@@ -57,6 +57,8 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
@@ -97,6 +99,7 @@ import {
import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
import { worktreeDeleteConfirmationStyles } from '../../../src/components/worktree-delete-confirmation-styles'
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
@@ -119,9 +122,12 @@ export function HostScreen({
action: actionProp,
onHideSidebar
}: HostScreenProps = {}) {
const params = useLocalSearchParams<{ hostId: string; action?: string }>()
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
const hostId = hostIdProp ?? params.hostId
const action = actionProp ?? params.action
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
const noticeParam = params.notice?.trim()
const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice)
const router = useRouter()
const pathname = usePathname()
const insets = useSafeAreaInsets()
@@ -470,7 +476,7 @@ export function HostScreen({
setWorktreesLoaded(true)
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
if (hostId) {
setCachedWorktrees(hostId, confirmed)
setCachedWorktrees(hostId, confirmed, { proven: true })
}
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
setOptimisticActiveWorktreeId((pending) =>
@@ -521,7 +527,8 @@ export function HostScreen({
useFocusEffect(
useCallback(() => {
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
clientRef.current?.notifyForeground()
// 'focus' keeps a healthy relay green — probe, never suspend (S2 grey blink).
clientRef.current?.notifyForeground('focus')
}, [])
)
@@ -743,10 +750,9 @@ export function HostScreen({
)
const displayWorktrees = useMemo(() => {
const base =
connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed'
? lastKnownWorktrees
: worktrees
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
const base = connState === 'connected' ? worktrees : lastKnownWorktrees
if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) {
return base
}
@@ -1109,6 +1115,14 @@ export function HostScreen({
/>
)}
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
{routeNotice && (
<HostRouteNoticeBanner
message={routeNotice}
onDismiss={() => setDismissedNotice(noticeParam ?? null)}
/>
)}
{/* Search bar */}
{showSearch && (
<View style={styles.searchBar}>
@@ -1292,28 +1306,28 @@ export function HostScreen({
>
{confirmDelete ? (
<View>
<View style={styles.confirmContent}>
<Text style={styles.confirmTitle}>Delete Worktree</Text>
<Text style={styles.confirmMessage}>
<View style={worktreeDeleteConfirmationStyles.content}>
<Text style={worktreeDeleteConfirmationStyles.title}>Delete Worktree</Text>
<Text style={worktreeDeleteConfirmationStyles.message}>
Delete "{confirmDelete.displayName || confirmDelete.repo}" ({confirmDelete.branch})?
</Text>
</View>
<View style={styles.confirmButtons}>
<View style={worktreeDeleteConfirmationStyles.buttons}>
<Pressable
style={({ pressed }) => [
styles.confirmBtn,
styles.confirmBtnCancel,
pressed && styles.confirmBtnPressed
worktreeDeleteConfirmationStyles.button,
worktreeDeleteConfirmationStyles.cancelButton,
pressed && worktreeDeleteConfirmationStyles.pressedButton
]}
onPress={() => setConfirmDelete(null)}
>
<Text style={styles.confirmBtnCancelText}>Cancel</Text>
<Text style={worktreeDeleteConfirmationStyles.cancelText}>Cancel</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.confirmBtn,
styles.confirmBtnDestructive,
pressed && styles.confirmBtnPressed
worktreeDeleteConfirmationStyles.button,
worktreeDeleteConfirmationStyles.destructiveButton,
pressed && worktreeDeleteConfirmationStyles.pressedButton
]}
onPress={() => {
if (confirmDelete) {
@@ -1323,7 +1337,7 @@ export function HostScreen({
setActionTarget(null)
}}
>
<Text style={styles.confirmBtnDestructiveText}>Delete</Text>
<Text style={worktreeDeleteConfirmationStyles.destructiveText}>Delete</Text>
</Pressable>
</View>
</View>
@@ -1684,48 +1698,5 @@ const styles = StyleSheet.create({
width: 8,
height: 8,
borderRadius: 4
},
confirmContent: {
paddingBottom: spacing.lg
},
confirmTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary
},
confirmMessage: {
fontSize: typography.bodySize,
color: colors.textSecondary,
marginTop: spacing.xs,
lineHeight: 20
},
confirmButtons: {
flexDirection: 'row',
gap: spacing.sm
},
confirmBtn: {
flex: 1,
paddingVertical: spacing.sm + 2,
borderRadius: 10,
alignItems: 'center'
},
confirmBtnCancel: {
backgroundColor: colors.bgPanel
},
confirmBtnDestructive: {
backgroundColor: colors.statusRed
},
confirmBtnPressed: {
opacity: 0.7
},
confirmBtnCancelText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textSecondary
},
confirmBtnDestructiveText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: '#fff'
}
})
+14 -3
View File
@@ -200,6 +200,9 @@ import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-d
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import { useMissingWorktreeBounce } from '../../../../src/session/use-missing-worktree-bounce'
import { hostRouteWithNotice } from '../../../../src/host-route-notice'
import { LAST_VISITED_WORKTREE_STORAGE_KEY } from '../../../../src/worktree/last-visited-worktree-repo'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
@@ -862,12 +865,19 @@ export default function SessionScreen() {
const lastConnectedAt = useLastConnectedAt(hostId)
const rpcUnresponsiveSince = useRpcUnresponsiveSince(hostId)
const forceReconnectHost = useForceReconnect()
const worktreeName = useLiveWorktreeName({
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
client,
connState,
routeName: routeWorktreeName,
worktreeId
})
// Why: a workspace deleted on the desktop leaves every RPC on this route failing forever.
useMissingWorktreeBounce({
hostId,
worktreeId,
resolution: worktreeResolution,
bounce: (id) => router.replace(hostRouteWithNotice(id, 'worktree-missing'))
})
// Master-detail state: wide layouts dock a tapped panel beside the session; narrow keeps it null and pushes full-screen routes.
const { isWideLayout } = useResponsiveLayout()
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
@@ -1696,7 +1706,8 @@ export default function SessionScreen() {
try {
const response = await client.sendRequest('terminal.list', {
worktree: `id:${worktreeId}`
worktree: `id:${worktreeId}`,
includeVisualLayouts: false
})
if (response.ok) {
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
@@ -2642,7 +2653,7 @@ export default function SessionScreen() {
useEffect(() => {
if (hostId && worktreeId) {
void AsyncStorage.setItem(
'orca:last-visited-worktree',
LAST_VISITED_WORKTREE_STORAGE_KEY,
JSON.stringify({ hostId, worktreeId })
)
}
+79 -58
View File
@@ -25,12 +25,8 @@ import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home
import type { RpcClient } from '../src/transport/rpc-client'
import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import {
useAllHostClients,
useCloseHost,
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import { startForceReconnectWithFeedback } from '../src/transport/force-reconnect-feedback'
import { useRpcUnresponsiveByHost } from '../src/transport/client-context-connection-metrics'
import { classifyConnection } from '../src/transport/connection-health'
@@ -47,7 +43,15 @@ import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { ActionSheetModal } from '../src/components/ActionSheetModal'
import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
import {
setCachedWorktrees,
getCachedWorktrees,
getProvenCachedWorktrees
} from '../src/cache/worktree-cache'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord
} from '../src/worktree/last-visited-worktree-repo'
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
import { colors, spacing, radii } from '../src/theme/mobile-theme'
import {
@@ -58,6 +62,13 @@ import {
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
import { useResponsiveLayout } from '../src/layout/responsive-layout'
import { useOpenMobileSession } from '../src/session/use-open-mobile-session'
import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts'
import {
isResumeTargetConfirmedMissing,
selectHomeResumeCard,
type HomeResumeCard
} from '../src/worktree/home-resume-card'
import { hostRouteWithNotice } from '../src/host-route-notice'
function endpointLabel(endpoint: string): string {
try {
@@ -212,6 +223,7 @@ export default function HomeScreen() {
const openMobileHostEdit = useOpenMobileHostEdit()
const openMobileTasks = useOpenMobileTasks()
const openMobileSession = useOpenMobileSession()
const openMobileAccounts = useOpenMobileAccounts()
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()
@@ -319,13 +331,13 @@ export default function HomeScreen() {
router.replace(mobileOnboardingDestination(onboardingSteps))
}
})
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
if (stale || !raw) {
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
if (stale) {
return
}
try {
setLastVisited(JSON.parse(raw))
} catch {}
// Why the validating reader: this record becomes the Resume card's navigation target,
// so a malformed or older-shaped payload must read as no history, not a broken route.
setLastVisited(readLastVisitedWorktreeRecord(raw))
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
@@ -488,28 +500,43 @@ export default function HomeScreen() {
.join(',')
])
// Why: prefer the worktree last opened on this device so Resume reflects mobile session history.
// Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC lands.
const resumeWorktree = useMemo(() => {
// Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load.
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
const cached = getCachedWorktrees(lastVisited.hostId) as HomeWorktreeSummary[] | null
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
if (match) {
return { hostId: lastVisited.hostId, worktree: match }
// Why: the card renders from cached/snapshot data the moment a candidate exists — see
// selectHomeResumeCard for why its slot must not wait for the host to connect.
const resumeCard = useMemo(
() =>
selectHomeResumeCard({
hosts: sortedHosts,
hostStates,
worktreeInfo,
lastVisited,
cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null
}),
[sortedHosts, hostStates, worktreeInfo, lastVisited]
)
// Why: the card is drawn from a snapshot that can name a workspace the desktop has since
// deleted. When the host has proven otherwise, open its workspace list rather than a session
// screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes
// through and the session screen bounces once the host answers (F7).
const openResume = useCallback(
(card: HomeResumeCard) => {
if (
isResumeTargetConfirmedMissing(
card,
getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
)
) {
router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
return
}
}
for (const host of sortedHosts) {
if (hostStates[host.id] !== 'connected') {
continue
}
const info = worktreeInfo[host.id]
if (info?.lastActiveWorktree) {
return { hostId: host.id, worktree: info.lastActiveWorktree }
}
}
return null
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
openMobileSession({
hostId: card.hostId,
worktreeId: card.worktree.worktreeId,
name: card.worktree.displayName || card.worktree.repo
})
},
[openMobileSession, router]
)
// Why: only show Account usage for connected hosts; stale cached usage would imply live data.
const accountsHosts = useMemo(() => {
@@ -551,7 +578,7 @@ export default function HomeScreen() {
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.taskHomeCard,
!primaryConnectedHost && styles.quickActionDisabled,
!primaryConnectedHost && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
@@ -765,51 +792,45 @@ export default function HomeScreen() {
ListFooterComponent={
<View>
{/* ─── Resume card ─── */}
{resumeWorktree ? (
{resumeCard ? (
<>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
<Pressable
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
onPress={() =>
openMobileSession({
hostId: resumeWorktree.hostId,
worktreeId: resumeWorktree.worktree.worktreeId,
name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
})
}
disabled={!resumeCard.actionable}
style={({ pressed }) => [
styles.resumeCard,
!resumeCard.actionable && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => openResume(resumeCard)}
>
<View style={styles.resumeIcon}>
<Terminal size={18} color={colors.textSecondary} />
</View>
<View style={styles.resumeMain}>
<Text style={styles.resumeTitle} numberOfLines={1}>
{resumeWorktree.worktree.displayName}
{resumeCard.worktree.displayName}
</Text>
<View style={styles.resumeSub}>
<View
style={[
styles.repoDot,
{ backgroundColor: repoColor(resumeWorktree.worktree.repo) }
{ backgroundColor: repoColor(resumeCard.worktree.repo) }
]}
/>
<Text style={styles.resumeSubText} numberOfLines={1}>
{resumeWorktree.worktree.repo}
{resumeCard.worktree.repo}
{' · '}
{resumeWorktree.worktree.branch}
{resumeCard.worktree.branch}
</Text>
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
</>
) : (
<>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
</>
)}
) : null}
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
@@ -827,7 +848,7 @@ export default function HomeScreen() {
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.quickAction,
!primaryConnectedHost && styles.quickActionDisabled,
!primaryConnectedHost && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
@@ -864,7 +885,7 @@ export default function HomeScreen() {
styles.accountsCard,
pressed && styles.hostCardPressed
]}
onPress={() => router.push(`/h/${host.id}/accounts`)}
onPress={() => openMobileAccounts(host.id)}
>
{showHostName ? (
<Text style={styles.accountsHostLabel} numberOfLines={1}>
@@ -1254,7 +1275,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: 10
},
quickActionDisabled: {
cardDisabled: {
opacity: 0.45
},
quickActionIcon: {
+1 -1
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/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
+1 -1
View File
@@ -14,7 +14,7 @@ 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/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
+1 -1
View File
@@ -139,7 +139,7 @@ async function chooseWorktree(ws: WebSocket): Promise<string> {
}
async function chooseTerminal(ws: WebSocket, worktree: string): Promise<string> {
const list = await send(ws, 'terminal.list', { worktree })
const list = await send(ws, 'terminal.list', { worktree, includeVisualLayouts: false })
if (!list.ok) {
throw new Error(`terminal.list failed: ${formatResponse(list)}`)
}
@@ -0,0 +1,96 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { mobileAccountsRouteTarget } from './mobile-accounts-route'
import {
hostStackHostRoute,
navigateToHostStackRoute,
type HostStackNavigationState
} from '../navigation/host-stack-navigation'
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
function navigationHarness(initialState: HostStackNavigationState) {
const stateListeners = new Set<() => void>()
let state = initialState
const navigation = {
addListener: vi.fn((_event: 'state', listener: () => void) => {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
}),
dispatch: vi.fn(),
getState: () => state
}
return {
navigation,
setState(nextState: HostStackNavigationState) {
state = nextState
for (const listener of stateListeners) {
listener()
}
}
}
}
describe('mobile accounts route', () => {
it('keeps the host id raw for the navigator to encode', () => {
expect(mobileAccountsRouteTarget('host/one')).toEqual({
name: '[hostId]/accounts',
params: { hostId: 'host/one' }
})
})
it('mounts the host before replacing it with the accounts route', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
navigateToHostStackRoute(
harness.navigation,
{ push },
'host/one',
mobileAccountsRouteTarget('host/one')
)
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
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: encodeURIComponent('host/one') }
}
]
}
}
]
})
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'REPLACE',
target: '/h',
source: 'host-index',
payload: mobileAccountsRouteTarget('host/one')
})
})
it('opens the home account-usage card through the cold-navigator-safe transition', () => {
const start = homeSource.indexOf('{/* ─── Account usage ─── */}')
// Assert the marker first: a renamed banner would otherwise slice garbage and report a
// missing call instead of the real cause.
expect(start).toBeGreaterThanOrEqual(0)
const accountsSection = homeSource.slice(start)
expect(accountsSection).toContain('openMobileAccounts(host.id)')
expect(accountsSection).not.toContain('/accounts`')
})
})
@@ -0,0 +1,10 @@
import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
/** Host id stays raw — the navigator owns the params, so pre-encoding one would
* reach the accounts screen still escaped. */
export function mobileAccountsRouteTarget(hostId: string): HostStackRouteTarget {
return {
name: '[hostId]/accounts',
params: { hostId }
}
}
@@ -0,0 +1,14 @@
import { useCallback } from 'react'
import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
import { mobileAccountsRouteTarget } from './mobile-accounts-route'
export function useOpenMobileAccounts(): (hostId: string) => void {
const openHostStackRoute = useOpenHostStackRoute()
return useCallback(
(hostId) => {
openHostStackRoute(hostId, mobileAccountsRouteTarget(hostId))
},
[openHostStackRoute]
)
}
+49 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
import { setCachedWorktrees, getCachedWorktrees, getProvenCachedWorktrees } from './worktree-cache'
// Why: AC #8498 guarantees a reconnect refetch writes through the
// same cache path the host detail screen seeds from, so a reconnect can't
@@ -41,3 +41,51 @@ describe('worktree-cache write-through', () => {
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
})
})
// Why (F7): home seeds this cache from a persisted cold-start snapshot as well as from a live
// worktree.ps, and only the latter can prove a workspace *absent* — the Resume tap redirects
// off that distinction, so a seeded entry must never look authoritative.
describe('worktree-cache provenance', () => {
it('withholds unmarked writes from the proven reader', () => {
const hostId = 'host-seeded'
const seeded = [{ worktreeId: 'a' }]
setCachedWorktrees(hostId, seeded)
expect(getCachedWorktrees(hostId)).toEqual(seeded)
expect(getProvenCachedWorktrees(hostId)).toBeNull()
})
it('exposes a host-listed catalog to the proven reader', () => {
const hostId = 'host-proven'
const listed = [{ worktreeId: 'a' }, { worktreeId: 'b' }]
setCachedWorktrees(hostId, listed, { proven: true })
expect(getProvenCachedWorktrees(hostId)).toEqual(listed)
})
it('keeps a fresh proven catalog when an unproven seed lands after it', () => {
const hostId = 'host-kept'
const listed = [{ worktreeId: 'a' }, { worktreeId: 'b' }]
setCachedWorktrees(hostId, listed, { proven: true })
// A cold-start snapshot seed must neither truncate nor de-prove the host-listed rows.
setCachedWorktrees(hostId, [{ worktreeId: 'a' }])
expect(getProvenCachedWorktrees(hostId)).toEqual(listed)
})
it('lets an unproven seed replace another unproven entry', () => {
const hostId = 'host-reseeded'
setCachedWorktrees(hostId, [{ worktreeId: 'a' }])
setCachedWorktrees(hostId, [{ worktreeId: 'b' }])
expect(getCachedWorktrees(hostId)).toEqual([{ worktreeId: 'b' }])
expect(getProvenCachedWorktrees(hostId)).toBeNull()
})
it('reports nothing proven for a host it has never cached', () => {
expect(getProvenCachedWorktrees('host-never-seen')).toBeNull()
})
})
+28 -3
View File
@@ -5,6 +5,9 @@
type CachedWorktrees = {
worktrees: unknown[]
at: number
// Whether the host itself listed these rows this session, as opposed to a cold-start seed
// rebuilt from a persisted snapshot. Only a proven list can prove a worktree *absent*.
proven: boolean
}
const cache = new Map<string, CachedWorktrees>()
@@ -12,12 +15,23 @@ const cache = new Map<string, CachedWorktrees>()
const MAX_AGE_MS = 30_000
const MAX_ENTRIES = 20
export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
export function setCachedWorktrees(
hostId: string,
worktrees: unknown[],
options?: { proven?: boolean }
): void {
// Why: a cold-start snapshot seed landing after a live worktree.ps must not erase
// the proof — or truncate the host-listed rows — the resume check depends on.
if (options?.proven !== true && readFreshEntry(hostId)?.proven) {
return
}
// Why: Map.set on an existing key does not move it to the end of iteration
// order. Delete first so the re-inserted key becomes the newest entry,
// giving us true LRU eviction when the cap is hit.
cache.delete(hostId)
cache.set(hostId, { worktrees, at: Date.now() })
// Default false: a caller that has not said where the rows came from must never be taken
// as grounds for redirecting the user away from a workspace.
cache.set(hostId, { worktrees, at: Date.now(), proven: options?.proven === true })
if (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value
if (oldest) {
@@ -27,6 +41,17 @@ export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
}
export function getCachedWorktrees(hostId: string): unknown[] | null {
return readFreshEntry(hostId)?.worktrees ?? null
}
/** The rows only when the host listed them itself — null whenever absence cannot be trusted,
* which is every unproven or expired entry. */
export function getProvenCachedWorktrees(hostId: string): unknown[] | null {
const entry = readFreshEntry(hostId)
return entry?.proven ? entry.worktrees : null
}
function readFreshEntry(hostId: string): CachedWorktrees | null {
const entry = cache.get(hostId)
if (!entry) {
return null
@@ -35,5 +60,5 @@ export function getCachedWorktrees(hostId: string): unknown[] | null {
cache.delete(hostId)
return null
}
return entry.worktrees
return entry
}
+67 -4
View File
@@ -1,4 +1,4 @@
import { createElement } from 'react'
import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
@@ -14,7 +14,10 @@ vi.mock('react-native', () => ({
Linking: { openURL: nativeTestState.openUrl },
Platform: nativeTestState.platform,
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles },
StyleSheet: {
create: <T>(styles: T) => styles,
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
},
Text: 'Text',
View: 'View'
}))
@@ -41,11 +44,20 @@ function GateConsumer() {
return createElement('GateStatus', null, hostCapabilities.join(','))
}
// Counts mounts so a test can prove the routes were never torn down, which presence alone can't.
const probeMounts = { count: 0 }
function MountProbe() {
useEffect(() => {
probeMounts.count += 1
}, [])
return createElement('MountProbe')
}
function gateElement() {
return createElement(
HostProtocolGate,
{ hostId: 'host-1' },
createElement('HostContent', null, createElement(GateConsumer))
createElement('HostContent', null, createElement(GateConsumer), createElement(MountProbe))
)
}
@@ -69,6 +81,7 @@ describe('HostProtocolGate', () => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
nativeTestState.openUrl.mockClear()
nativeTestState.platform.OS = 'ios'
probeMounts.count = 0
})
afterEach(() => {
@@ -157,9 +170,56 @@ describe('HostProtocolGate', () => {
const output = renderedText(renderer)
expect(output).toContain('Checking host compatibility')
expect(output).not.toContain('HostContent')
expect(probeMounts.count).toBe(0)
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('overlays the pending spinner instead of unmounting routes mounted while connecting', async () => {
hostClient.current = { client: null, state: 'connecting' }
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
expect(probeMounts.count).toBe(1)
const client = {
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
} as unknown as RpcClient
await act(async () => {
hostClient.current = { client, state: 'connected' }
renderer?.update(gateElement())
await Promise.resolve()
})
const output = renderedText(renderer)
expect(output).toContain('HostContent')
expect(output).toContain('Checking host compatibility')
// Why: the cold-start remount this replaces is exactly what destroys in-flight deep navigation.
expect(probeMounts.count).toBe(1)
const overlay = renderer.root
.findAllByType('View')
.find((node) => node.props.accessibilityViewIsModal === true)
expect(overlay?.props.pointerEvents).toBe('auto')
})
it('still replaces mounted routes when the verdict comes back blocked', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
hostClient.current = { client: null, state: 'connecting' }
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
await act(async () => {
hostClient.current = {
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
state: 'connected'
}
renderer?.update(gateElement())
await Promise.resolve()
})
const output = renderedText(renderer)
expect(output).toContain('Update Orca Mobile')
expect(output).not.toContain('HostContent')
})
it('keeps an already-validated host route mounted while reconnect status is pending', async () => {
const client = {
sendRequest: vi
@@ -183,7 +243,10 @@ describe('HostProtocolGate', () => {
await Promise.resolve()
})
expect(renderedText(renderer)).toContain('HostContent')
const output = renderedText(renderer)
expect(output).toContain('HostContent')
// Why: the host already answered once, so a reconnect probe must not dim the UI it validated.
expect(output).not.toContain('Checking host compatibility')
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
+70 -8
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useRef, type ReactNode } from 'react'
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react'
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import { useHostClient } from '../transport/client-context'
import { useHostStatusGates, type HostStatusGates } from '../transport/host-status-gates'
@@ -27,12 +27,31 @@ export function HostProtocolGate({ hostId, children }: Props) {
const gates = useHostStatusGates({ hostId, client, connState: state })
const { compatVerdict, statusPending } = gates
const resolvedHostIdRef = useRef<string | null>(null)
const mountedHostIdRef = useRef<string | null>(null)
const hostKey = hostId ?? null
if (state === 'connected' && client && !statusPending) {
resolvedHostIdRef.current = hostKey
}
if (statusPending && resolvedHostIdRef.current !== hostKey) {
// Why: child routes may call newer RPCs on mount, so wait until compatibility is known.
const resolvedNow = state === 'connected' && client !== null && !statusPending
const blocked = compatVerdict.kind === 'blocked'
const pending = statusPending && resolvedHostIdRef.current !== hostKey
const holdBack = pending && mountedHostIdRef.current !== hostKey
// Why: React can replay or discard a render, so the latches record committed
// outcomes only — a discarded children render must not count as mounted.
useEffect(() => {
if (resolvedNow) {
resolvedHostIdRef.current = hostKey
}
if (blocked) {
// Why: the block screen unmounts the routes, so a later pending window
// must not assume a live tree it can overlay.
mountedHostIdRef.current = null
} else if (!holdBack) {
mountedHostIdRef.current = hostKey
}
})
if (holdBack) {
// Why: nothing is mounted yet for this host, so hold the routes back entirely
// rather than letting them mount (and fire their connect RPCs) pre-verdict.
return (
<View style={styles.pending}>
<ActivityIndicator
@@ -42,11 +61,42 @@ export function HostProtocolGate({ hostId, children }: Props) {
</View>
)
}
if (compatVerdict.kind === 'blocked') {
if (blocked) {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
// Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route.
return <HostStatusGatesContext.Provider value={gates}>{children}</HostStatusGatesContext.Provider>
return (
<HostStatusGatesContext.Provider value={gates}>
<View style={styles.host}>
<View
style={styles.host}
// Why: the overlay blocks in-tree touches but TalkBack can still walk the covered
// stack; hide it while pending (Android counterpart of the overlay's iOS modal flag).
importantForAccessibility={pending ? 'no-hide-descendants' : 'auto'}
>
{children}
</View>
{pending ? (
// Why: once the stack is mounted, unmounting it for a pending status.get destroys
// in-flight nested navigation, so cover it instead. Mount effects underneath still
// run — they wait for connState 'connected' and every capability-dependent call
// re-probes status.get itself, so nothing newer than the baseline fires here.
<View
style={styles.pendingOverlay}
// Why: the fill owns the hit test for in-tree views only — native-Modal-hosted
// surfaces (bottom drawers) present in a separate window above it and stay live.
pointerEvents="auto"
accessibilityViewIsModal
>
<ActivityIndicator
color={colors.textSecondary}
accessibilityLabel="Checking host compatibility"
/>
</View>
) : null}
</View>
</HostStatusGatesContext.Provider>
)
}
const styles = StyleSheet.create({
@@ -55,5 +105,17 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgBase
},
// Stays mounted across the overlay toggling so the routes below keep their identity.
host: {
flex: 1
},
pendingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgBase,
zIndex: 1000,
elevation: 1000
}
})
@@ -0,0 +1,43 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { X } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
// Informational, not an error: the host is healthy and the user's target simply went away,
// so this stays monochrome rather than borrowing the auth-failed red.
export function HostRouteNoticeBanner({
message,
onDismiss
}: {
message: string
onDismiss: () => void
}) {
return (
<View style={styles.banner}>
<Text style={styles.text}>{message}</Text>
<Pressable
onPress={onDismiss}
accessibilityRole="button"
accessibilityLabel="Dismiss notice"
hitSlop={spacing.sm}
style={styles.dismiss}
>
<X size={16} color={colors.textMuted} />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
backgroundColor: colors.bgPanel,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
text: { flex: 1, color: colors.textSecondary, fontSize: 13 },
dismiss: { padding: spacing.xs }
})
+54 -5
View File
@@ -2,7 +2,8 @@ import { createElement } from 'react'
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 { HostProfile } from '../transport/types'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import {
markHomeWorktreeCatalogUnavailable,
type HostWorktreeInfo
@@ -47,14 +48,21 @@ describe('MobileHostCard', () => {
renderer = null
})
async function renderCard(worktreeInfo: HostWorktreeInfo | undefined): Promise<string[]> {
async function renderCard(
worktreeInfo: HostWorktreeInfo | undefined,
overrides?: {
state?: ConnectionState
verdict?: ConnectionVerdict
path?: MobileConnectionPath
}
): Promise<string[]> {
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host,
state: 'connected',
verdict,
path: 'lan',
state: overrides?.state ?? 'connected',
verdict: overrides?.verdict ?? verdict,
path: overrides?.path ?? 'lan',
worktreeInfo,
onPress: () => {},
onLongPress: () => {}
@@ -84,6 +92,47 @@ describe('MobileHostCard', () => {
)
})
it('names the relay while the dial is still in flight', async () => {
const lines = await renderCard(undefined, {
state: 'connecting',
verdict: { kind: 'normal', label: 'Connecting…' },
path: 'relay'
})
expect(lines).toContain('Connecting…')
expect(lines).toContain(' · Orca Relay')
})
it('names the relay while a failed direct dial is still retrying', async () => {
const lines = await renderCard(undefined, {
state: 'reconnecting',
verdict: { kind: 'normal', label: 'Reconnecting…' },
path: 'relay'
})
expect(lines).toContain(' · Orca Relay')
})
it('leaves an idle disconnected host unlabelled', async () => {
const lines = await renderCard(undefined, {
state: 'disconnected',
verdict: { kind: 'normal', label: 'Disconnected' },
path: 'relay'
})
expect(lines).not.toContain(' · Orca Relay')
})
it('does not guess a direct path before the dial resolves', async () => {
const lines = await renderCard(undefined, {
state: 'connecting',
verdict: { kind: 'normal', label: 'Connecting…' },
path: 'lan'
})
expect(lines).not.toContain(' · Direct · LAN')
})
it('shows no worktree line before the first read lands', async () => {
const lines = await renderCard(undefined)
+6 -1
View File
@@ -22,6 +22,11 @@ export function MobileHostCard(props: {
onLongPress: () => void
}) {
const connected = props.state === 'connected'
// 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 worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
const supportingMessage = verdictSupportingMessage(props.verdict)
@@ -46,7 +51,7 @@ export function MobileHostCard(props: {
<StatusDot state={props.state} verdict={props.verdict} />
<Text style={[styles.metaText, isError && { color: colors.statusRed }]} numberOfLines={1}>
{verdictDisplayLabel(props.verdict)}
{connected ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
{connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
</Text>
</View>
{connected && worktreeSummary ? (
@@ -0,0 +1,98 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
FlatList: 'FlatList',
Image: 'Image',
Keyboard: { dismiss: vi.fn() },
Linking: { openURL: vi.fn() },
Modal: 'Modal',
ScrollView: 'ScrollView',
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles },
Switch: 'Switch',
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
// Every icon in the drawer tree renders as a host element named after itself.
vi.mock(
'lucide-react-native',
() =>
new Proxy(
{},
{
get: (_target, name) => (typeof name === 'string' ? name : undefined),
has: () => true
}
)
)
vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
vi.mock('./bottom-drawer-modal-host', () => ({ BottomDrawerModalHost: 'BottomDrawerModalHost' }))
vi.mock('./PickerListDrawer', () => ({ PickerListDrawer: 'PickerListDrawer' }))
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
import { setCachedRepos } from '../cache/repo-cache'
import { NewWorktreeModal } from './NewWorktreeModal'
const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }]
function repoPickerNames(renderer: ReactTestRenderer | null): string[] {
const pickers = renderer?.root.findAll((node) => node.type === 'PickerListDrawer') ?? []
const repoPicker = pickers.find((node) => node.props.title === 'Repository')
return ((repoPicker?.props.items ?? []) as { label: string }[]).map((item) => item.label)
}
describe('NewWorktreeModal repo list', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
setCachedRepos('host-1', repos)
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
const sendRequest = vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.reject(new Error('connection closed'))
}
return new Promise(() => {})
})
const client = { sendRequest } as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
})
)
await Promise.resolve()
})
await act(async () => {
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledWith('repo.list')
expect(repoPickerNames(renderer)).toEqual(['orca'])
})
})
+3 -5
View File
@@ -327,11 +327,9 @@ function NewWorktreeModalContent({
})
}
})
.catch(() => {
if (!stale) {
setRepos([])
}
})
// Why (F10): a dropped connection rejects this call — keep the last-good list (the content
// remounts with the new host's cache when the client changes) instead of emptying the picker.
.catch(() => undefined)
.finally(() => {
if (!stale) {
setLoading(false)
@@ -0,0 +1,48 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
export const worktreeDeleteConfirmationStyles = StyleSheet.create({
content: {
paddingBottom: spacing.lg
},
title: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary
},
message: {
fontSize: typography.bodySize,
color: colors.textSecondary,
marginTop: spacing.xs,
lineHeight: 20
},
buttons: {
flexDirection: 'row',
gap: spacing.sm
},
button: {
flex: 1,
paddingVertical: spacing.sm + 2,
borderRadius: 10,
alignItems: 'center'
},
cancelButton: {
backgroundColor: colors.bgPanel
},
destructiveButton: {
backgroundColor: colors.statusRed
},
pressedButton: {
opacity: 0.7
},
cancelText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textSecondary
},
destructiveText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: '#fff'
}
})
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import {
HOST_ROUTE_NOTICES,
hostRouteNoticeMessage,
hostRouteWithNotice,
visibleHostRouteNotice
} from './host-route-notice'
describe('hostRouteNoticeMessage', () => {
it('maps a known code to its banner text', () => {
expect(hostRouteNoticeMessage('worktree-missing')).toBe(HOST_ROUTE_NOTICES['worktree-missing'])
})
it('renders nothing for absent or unrecognized codes', () => {
expect(hostRouteNoticeMessage(undefined)).toBeNull()
expect(hostRouteNoticeMessage('')).toBeNull()
// A newer build's code must not leak the raw param into the UI.
expect(hostRouteNoticeMessage('some-future-code')).toBeNull()
})
// A plain lookup returns Object.prototype members, which would hand the banner a function.
it('renders nothing for prototype keys', () => {
expect(hostRouteNoticeMessage('toString')).toBeNull()
expect(hostRouteNoticeMessage('constructor')).toBeNull()
expect(hostRouteNoticeMessage('__proto__')).toBeNull()
})
})
describe('hostRouteWithNotice', () => {
it('encodes the host id into the noticed route', () => {
expect(hostRouteWithNotice('host/one', 'worktree-missing')).toBe(
'/h/host%2Fone?notice=worktree-missing'
)
})
})
describe('visibleHostRouteNotice', () => {
const message = HOST_ROUTE_NOTICES['worktree-missing']
it('shows a notice the user has not dismissed', () => {
expect(visibleHostRouteNotice(false, 'worktree-missing', null)).toBe(message)
})
it('stays silent once that code is dismissed', () => {
expect(visibleHostRouteNotice(false, 'worktree-missing', 'worktree-missing')).toBeNull()
})
// Dismissal is keyed by code so a later, different bounce still gets to speak.
it('still shows a different code after one was dismissed', () => {
expect(visibleHostRouteNotice(false, 'worktree-missing', 'some-other-code')).toBe(message)
})
it('draws nothing in the embedded sidebar, which shares the route', () => {
expect(visibleHostRouteNotice(true, 'worktree-missing', null)).toBeNull()
})
})
+38
View File
@@ -0,0 +1,38 @@
// Why a route param rather than a toast: the screen that learns the bad news (the session)
// unmounts as it bounces, so the message has to travel with the navigation to survive.
export const HOST_ROUTE_NOTICES = {
'worktree-missing': 'That workspace no longer exists on this host.'
} as const
export type HostRouteNotice = keyof typeof HOST_ROUTE_NOTICES
/** The banner text for a route param, or null when absent/unrecognized — an unknown code
* from a future build must render nothing rather than leak the raw param. */
export function hostRouteNoticeMessage(notice: string | undefined): string | null {
// Why hasOwn: the param is attacker-adjacent URL text, and a plain lookup of 'toString'
// would hand the banner a function off the prototype instead of missing.
if (!notice || !Object.hasOwn(HOST_ROUTE_NOTICES, notice)) {
return null
}
return HOST_ROUTE_NOTICES[notice as HostRouteNotice]
}
export function hostRouteWithNotice(hostId: string, notice: HostRouteNotice): string {
return `/h/${encodeURIComponent(hostId)}?notice=${notice}`
}
/** The banner the host screen should draw, if any.
* `embedded` is the tablet sidebar, which shares the route with the routed screen — one
* bounce must not draw two banners. `dismissed` is keyed by code rather than a boolean so
* closing one notice cannot swallow a later, different one. */
export function visibleHostRouteNotice(
embedded: boolean,
notice: string | undefined,
dismissed: string | null
): string | null {
if (embedded || (notice && notice === dismissed)) {
return null
}
return hostRouteNoticeMessage(notice)
}
@@ -14,7 +14,7 @@ const OTHER_TARGET = {
// Removal is modeled for real: a no-op unsubscribe would let `setState` keep
// calling a canceled listener, testing the `active` guard instead of teardown.
function navigationHarness(initialState: HostStackNavigationState) {
function navigationHarness(initialState: HostStackNavigationState | undefined) {
const stateListeners = new Set<() => void>()
let state = initialState
const unsubscribe = vi.fn()
@@ -33,7 +33,7 @@ function navigationHarness(initialState: HostStackNavigationState) {
navigation,
unsubscribe,
listenerCount: () => stateListeners.size,
setState(nextState: HostStackNavigationState) {
setState(nextState: HostStackNavigationState | undefined) {
state = nextState
for (const listener of stateListeners) {
listener()
@@ -58,6 +58,12 @@ function committedHostState(hostIdParam: string): HostStackNavigationState {
}
}
// The shape app/_layout.tsx sees: Expo Router mounts it as a screen of its own internal
// navigator, so every route the host stack lives in sits one level below.
function rootLayoutScopedState(inner: HostStackNavigationState): HostStackNavigationState {
return { key: 'internal', index: 0, routes: [{ key: '__root', name: '__root', state: inner }] }
}
describe('host stack navigation', () => {
it('matches a host committed as the encoded segment it was pushed as', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
@@ -78,6 +84,46 @@ describe('host stack navigation', () => {
})
})
it('replaces the host stack seen from the root layout, one navigator further down', () => {
const harness = navigationHarness(
rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] })
)
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
harness.setState(rootLayoutScopedState(committedHostState('host/one')))
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'REPLACE',
target: '/h',
source: 'host-index',
payload: TARGET
})
})
it('survives the state emitted before the root navigator has hydrated', () => {
const harness = navigationHarness(undefined)
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
expect(() => harness.setState(undefined)).not.toThrow()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState(rootLayoutScopedState(committedHostState('host/one')))
expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1)
})
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)
harness.setState({ index: 0, routes: [{ name: 'h' }] })
harness.setState({ index: 0, routes: [{ name: 'index' }] })
harness.setState(committedHostState('host/one'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
expect(harness.listenerCount()).toBe(0)
})
it('ignores a different host whose id merely decodes badly', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+29 -8
View File
@@ -28,7 +28,9 @@ export type HostStackReplaceAction = Readonly<{
export type HostStackRootNavigation = {
addListener: (event: 'state', listener: () => void) => () => void
dispatch: (action: HostStackReplaceAction) => void
getState: () => HostStackNavigationState
// Why: the root layout's navigator has no committed state until it hydrates, and a
// notification tap can arm the transition before that first commit.
getState: () => HostStackNavigationState | undefined
}
export type HostStackHostRoute = `/h/${string}`
@@ -68,15 +70,31 @@ function hostParamMatches(param: unknown, expectedHostId: string): boolean {
}
}
/** The focused `h` route, however deep the caller's navigator sits above it: a screen
* inside the root stack sees it at the top, but app/_layout.tsx is itself a screen of
* Expo Router's internal navigator, so from there the root stack is one level down. */
function focusedHostRoute(state: HostStackNavigationState): HostStackNavigationRoute | null {
let current: HostStackNavigationState | undefined = state
while (current) {
const route: HostStackNavigationRoute | undefined = current.routes[current.index]
if (!route) {
return null
}
if (route.name === 'h') {
return route
}
current = route.state
}
return null
}
function mountedHostStack(
state: HostStackNavigationState,
hostContainer: HostStackNavigationRoute,
expectedHostId: string
): { key: string; routeKey: string } | null {
const hostContainer = state.routes[state.index]
const hostState = hostContainer?.state
const hostState = hostContainer.state
const hostRoute = hostState?.routes[hostState.index]
if (
hostContainer?.name !== 'h' ||
!hostState?.key ||
hostRoute?.name !== '[hostId]/index' ||
!hostRoute.key ||
@@ -114,14 +132,17 @@ export function navigateToHostStackRoute(
return
}
const state = navigation.getState()
const currentRoute = state.routes[state.index]
if (currentRoute?.name === 'h') {
if (!state) {
return
}
const hostContainer = focusedHostRoute(state)
if (hostContainer) {
hostRouteSeen = true
} else if (hostRouteSeen) {
dispose()
return
}
const hostStack = mountedHostStack(state, hostId)
const hostStack = hostContainer && mountedHostStack(hostContainer, hostId)
if (!hostStack) {
return
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useNavigation, useRouter } from 'expo-router'
import {
coordinateHostStackNavigation,
@@ -14,11 +14,17 @@ let pendingNavigation: PendingHostStackNavigation | null = null
export function useOpenHostStackRoute(): (hostId: string, target: HostStackRouteTarget) => void {
const navigation = useNavigation<HostStackRootNavigation>()
const router = useRouter()
// Why: an unmounting screen may only cancel a transition it armed itself — home
// unmounting for onboarding must not kill a notification push still in flight.
const armedRef = useRef<PendingHostStackNavigation | null>(null)
useEffect(
() => () => {
pendingNavigation?.controller.cancel()
pendingNavigation = null
if (armedRef.current && armedRef.current === pendingNavigation) {
pendingNavigation.controller.cancel()
pendingNavigation = null
}
armedRef.current = null
},
[]
)
@@ -32,6 +38,7 @@ export function useOpenHostStackRoute(): (hostId: string, target: HostStackRoute
hostId,
target
)
armedRef.current = pendingNavigation
},
[navigation, router]
)
@@ -0,0 +1,112 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { getNotificationNavigationTarget } from './notification-routing'
import {
hostStackHostRoute,
navigateToHostStackRoute,
type HostStackNavigationState
} from '../navigation/host-stack-navigation'
const rootLayoutSource = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8')
function navigationHarness(initialState: HostStackNavigationState | undefined) {
const stateListeners = new Set<() => void>()
let state = initialState
const navigation = {
addListener: vi.fn((_event: 'state', listener: () => void) => {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
}),
dispatch: vi.fn(),
getState: () => state
}
return {
navigation,
setState(nextState: HostStackNavigationState | undefined) {
state = nextState
for (const listener of stateListeners) {
listener()
}
}
}
}
// A notification tap is handled by app/_layout.tsx, which Expo Router mounts as a screen of its
// own internal navigator — hence the extra `__root` level around the app's root stack.
function rootLayoutScopedState(inner: HostStackNavigationState): HostStackNavigationState {
return { key: 'internal', index: 0, routes: [{ key: '__root', name: '__root', state: inner }] }
}
describe('notification route coordination', () => {
it('mounts the host before replacing it with the notification session, from a cold navigator', () => {
const target = getNotificationNavigationTarget({
hostId: 'host/one',
worktreeId: 'repo::/Users/me/orca/workspaces/feature'
})
// Cold start: the tap is handled before the root navigator has committed any state.
const harness = navigationHarness(undefined)
const push = vi.fn()
navigateToHostStackRoute(harness.navigation, { push }, target!.hostId, target!.sessionTarget!)
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState(rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] }))
harness.setState(
rootLayoutScopedState({
index: 1,
routes: [{ name: 'index' }, { name: 'h', state: undefined }]
})
)
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState(
rootLayoutScopedState({
index: 1,
routes: [
{ name: 'index' },
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [
{
key: 'host-index',
name: '[hostId]/index',
params: { hostId: encodeURIComponent('host/one') }
}
]
}
}
]
})
)
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'REPLACE',
target: '/h',
source: 'host-index',
payload: target!.sessionTarget
})
})
it('leaves a host-only notification as a shallow push with nothing to coordinate', () => {
expect(getNotificationNavigationTarget({ hostId: 'host-1' })?.sessionTarget).toBeNull()
})
it('routes notification taps through the coordinated transition, not a bare push', () => {
const start = rootLayoutSource.indexOf('// ─── Notification tap routing ───')
const end = rootLayoutSource.indexOf('// ─── End notification tap routing ───', start)
// Assert the markers first: a renamed banner would otherwise slice garbage and report a
// missing call instead of the real cause.
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const notificationEffect = rootLayoutSource.slice(start, end)
expect(notificationEffect).toContain('openNotificationRoute(target)')
expect(notificationEffect).not.toContain('router.push(')
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildLocalNotificationData, getNotificationNavigationPath } from './notification-routing'
import { buildLocalNotificationData, getNotificationNavigationTarget } from './notification-routing'
describe('notification routing', () => {
it('includes the host id in locally scheduled notification data', () => {
@@ -20,26 +20,36 @@ describe('notification routing', () => {
})
})
// Identities stay raw: the target is dispatched as navigator params, not a URL.
it('routes notification taps to the worktree terminal screen', () => {
expect(
getNotificationNavigationPath({
getNotificationNavigationTarget({
hostId: 'host-1',
worktreeId: 'repo::/Users/me/orca/workspaces/feature'
})
).toBe('/h/host-1/session/repo%3A%3A%2FUsers%2Fme%2Forca%2Fworkspaces%2Ffeature')
).toEqual({
hostId: 'host-1',
sessionTarget: {
name: '[hostId]/session/[worktreeId]',
params: { hostId: 'host-1', worktreeId: 'repo::/Users/me/orca/workspaces/feature' }
}
})
})
it('falls back to the host screen when the payload has no worktree id', () => {
expect(getNotificationNavigationPath({ hostId: 'host-1' })).toBe('/h/host-1')
expect(getNotificationNavigationTarget({ hostId: 'host-1' })).toEqual({
hostId: 'host-1',
sessionTarget: null
})
})
it('ignores payloads that cannot identify the paired host', () => {
expect(getNotificationNavigationPath({ worktreeId: 'repo::/tmp/worktree' })).toBeNull()
expect(getNotificationNavigationTarget({ worktreeId: 'repo::/tmp/worktree' })).toBeNull()
})
it('ignores payloads for hosts that are no longer paired', () => {
expect(
getNotificationNavigationPath(
getNotificationNavigationTarget(
{ hostId: 'removed-host', worktreeId: 'repo::/tmp/worktree' },
{ knownHostIds: new Set(['host-1']) }
)
@@ -1,3 +1,6 @@
import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
import { mobileSessionRouteTarget } from '../session/mobile-session-route'
export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test'
export type DesktopNotificationEvent = {
@@ -38,10 +41,17 @@ export function buildLocalNotificationData(
return data
}
export function getNotificationNavigationPath(
/** Where a tap should land. `sessionTarget` is null for a host-only notification, whose
* `/h/<id>` push is shallow enough to need no host-stack coordination. */
export type NotificationNavigationTarget = Readonly<{
hostId: string
sessionTarget: HostStackRouteTarget | null
}>
export function getNotificationNavigationTarget(
data: unknown,
options: NotificationNavigationOptions = {}
): string | null {
): NotificationNavigationTarget | null {
if (!data || typeof data !== 'object') {
return null
}
@@ -55,11 +65,9 @@ export function getNotificationNavigationPath(
return null
}
const hostPath = `/h/${encodeURIComponent(hostId)}`
const worktreeId = readNonEmptyString(record.worktreeId)
if (!worktreeId) {
return hostPath
return {
hostId,
sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null
}
return `${hostPath}/session/${encodeURIComponent(worktreeId)}`
}
@@ -0,0 +1,21 @@
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'
export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void {
const openHostStackRoute = useOpenHostStackRoute()
const router = useRouter()
return useCallback(
(target) => {
if (target.sessionTarget) {
openHostStackRoute(target.hostId, target.sessionTarget)
return
}
router.push(hostStackHostRoute(target.hostId))
},
[openHostStackRoute, router]
)
}
@@ -1,6 +1,7 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { radii, spacing } from '../theme/mobile-theme'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
vi.mock('react-native', async () => {
@@ -8,6 +9,7 @@ vi.mock('react-native', async () => {
return {
ActivityIndicator: 'ActivityIndicator',
Image: 'Image',
Keyboard: { dismiss: vi.fn() },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
@@ -23,12 +25,24 @@ vi.mock('react-native', async () => {
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
ImagePlus: 'ImagePlus',
Mic: 'Mic',
Square: 'Square',
X: 'X'
}))
vi.mock('../components/BottomDrawer', async () => {
const React = await import('react')
return {
BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) =>
visible ? React.createElement('BottomDrawer', { visible }, children) : null
}
})
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
@@ -90,10 +104,149 @@ describe('MobileNativeChatComposer', () => {
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('hello')
expect(onSend).toHaveBeenCalledWith(' hello')
expect(onChangeText).not.toHaveBeenCalled()
})
it('stacks the input above the composer action row', async () => {
await render(vi.fn().mockResolvedValue(true), vi.fn())
const composer = renderer!.root.findByProps({ testID: 'native-chat-composer' })
const inset = renderer!.root.findByProps({ testID: 'native-chat-composer-inset' })
const actions = renderer!.root.findByProps({ testID: 'native-chat-composer-actions' })
expect(composer.findAllByType('TextInput')).toHaveLength(1)
expect(composer.children[1]).toBe(actions)
expect(inset.props.style).toMatchObject({
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.md
})
expect(composer.props.style).toMatchObject({
borderWidth: 1,
borderRadius: radii.card,
overflow: 'hidden'
})
})
it('preserves leading whitespace so prose is not turned into a slash command', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' /clear is prose ',
onChangeText: vi.fn(),
onSend
})
)
})
} finally {
restore()
}
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
})
it('locks the option pickers while a composer send is in flight', async () => {
// The reverse of the test below. The host spaces a send's body and its Enter
// ~500ms apart, so an apply tapped inside that window would be submitted as
// part of the user's prompt instead of running as its own command.
let releaseSend: ((accepted: boolean) => void) | undefined
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
releaseSend = resolve
})
)
const controller = {
snapshot: [
{
id: 'model',
label: 'Model',
category: 'model' as const,
kind: {
type: 'select' as const,
choices: [
{ value: 'sonnet', label: 'Sonnet 5' },
{ value: 'opus', label: 'Opus 4.8' }
]
},
valueSource: 'unknown' as const,
settable: true
}
],
pendingId: null,
setOption: vi.fn(),
invokeAction: vi.fn(),
recordCommand: vi.fn()
}
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'run the tests',
onChangeText: vi.fn(),
onSend,
sessionOptions: { isWorking: false, controller }
})
)
})
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
) as { props: { accessibilityState: { disabled: boolean } } }
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
// Start the send but don't await it — it stays in flight on purpose.
let pressed!: Promise<void>
await act(async () => {
pressed = sendButton().props.onPress()
await Promise.resolve()
})
expect(onSend).toHaveBeenCalled()
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
await act(async () => {
releaseSend?.(true)
await pressed
})
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
} finally {
restore()
}
})
it('blocks composer submission while a session-option command is pending', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sessionOptions: {
isWorking: false,
controller: {
snapshot: [],
pendingId: 'model',
setOption: vi.fn(),
invokeAction: vi.fn(),
recordCommand: vi.fn()
}
}
})
)
})
} finally {
restore()
}
expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
})
it('keeps the draft when the send is rejected', async () => {
const onChangeText = vi.fn()
const onSend = vi.fn().mockResolvedValue(false)
@@ -101,7 +254,7 @@ describe('MobileNativeChatComposer', () => {
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('hello')
expect(onSend).toHaveBeenCalledWith(' hello')
expect(onChangeText).not.toHaveBeenCalled()
})
@@ -202,7 +355,8 @@ describe('MobileNativeChatComposer', () => {
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true)
onSend: vi.fn().mockResolvedValue(true),
agent: 'claude'
})
)
})
@@ -235,6 +389,36 @@ describe('MobileNativeChatComposer', () => {
expect(input().props.selection).toBeUndefined()
})
it('serves the active agent’s shared command catalog with descriptions', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
agent: 'codex'
})
)
})
} finally {
restore()
}
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void }
}
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 1 } } }))
const texts = renderer!.root
.findAll((node) => node.type === 'Text')
.map((node) => (node.props as { children?: unknown }).children)
// Codex-only commands from the shared catalog, with their description rows —
// and none of the old hardcoded provider-agnostic list's phantom entries.
expect(texts).toContain('/permissions')
expect(texts).toContain('Choose what Codex is allowed to do')
expect(texts).not.toContain('/cost')
})
it('wires the mic for hold vs toggle dictation like the terminal composer', async () => {
const onMicPress = vi.fn()
const onMicPressIn = vi.fn()
+149 -133
View File
@@ -5,32 +5,29 @@ import {
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { getVerifiedNativeChatCommands } from '../../../src/shared/native-chat-agent-profiles'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSlashCommandSuggestions,
rankSuggestions
} from './mobile-native-chat-autocomplete'
import {
composerSuggestionInsertText,
MobileNativeChatComposerSuggestions,
type ComposerSuggestion
} from './MobileNativeChatComposerSuggestions'
import {
MobileNativeChatSessionOptionPickers,
type MobileNativeChatSessionOptionPickersProps
} from './MobileNativeChatSessionOptionPickers'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
// Common agent slash commands offered as autocomplete; sending them is just text
// to the agent's terminal, so the set is intentionally provider-agnostic.
const SLASH_COMMANDS = [
'/clear',
'/compact',
'/review',
'/model',
'/help',
'/init',
'/cost',
'/diff'
]
const NO_FILE_PATHS: string[] = []
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
@@ -39,6 +36,11 @@ type Props = {
value: string
onChangeText: (text: string) => void
onSend: (text: string) => Promise<boolean>
/** Active tab's agent — the slash autocomplete serves its command catalog. */
agent?: string | null
/** Model/session-option pickers shown in the composer action row; null when
* the agent has no session-option catalog. */
sessionOptions?: MobileNativeChatSessionOptionPickersProps | null
onAttachImage?: () => void
/** Images picked-and-uploaded but not yet sent — shown as removable thumbnails
* and ridden along on the next send (desktop native-chat parity). */
@@ -61,6 +63,8 @@ export function MobileNativeChatComposer({
value,
onChangeText,
onSend,
agent,
sessionOptions,
onAttachImage,
attachments = NO_ATTACHMENTS,
onRemoveAttachment,
@@ -85,21 +89,36 @@ export function MobileNativeChatComposer({
const sendingRef = useRef(false)
const [sending, setSending] = useState(false)
const trimmed = value.trim()
const sessionOptionDispatching = sessionOptions?.controller.pendingId != null
// An attached image alone is a valid send (desktop parity), so the image rides
// along even when the user sends no accompanying text.
const canSend =
(trimmed.length > 0 || attachments.length > 0) && !disabled && !sending && !isAttaching
(trimmed.length > 0 || attachments.length > 0) &&
!disabled &&
!sending &&
!isAttaching &&
!sessionOptionDispatching
const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor])
const suggestions = useMemo(() => {
const suggestions = useMemo<ComposerSuggestion[]>(() => {
if (!trigger) {
return []
}
if (trigger.kind === 'slash') {
return rankSuggestions(SLASH_COMMANDS, trigger.query)
const commands = agent ? getVerifiedNativeChatCommands(agent) : []
// Why: Codex's catalog is 45 commands and this list is a plain ScrollView
// (~5 rows visible), so an uncapped `/` would mount every row and
// re-reconcile them on each streaming tick right above the transcript.
return rankSlashCommandSuggestions(commands, trigger.query, 12).map((command) => ({
kind: 'command' as const,
command
}))
}
return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`)
}, [trigger, filePaths])
return rankSuggestions(filePaths, trigger.query).map((path) => ({
kind: 'file' as const,
path
}))
}, [trigger, filePaths, agent])
useEffect(() => {
if (trigger?.kind === 'file') {
@@ -111,11 +130,15 @@ export function MobileNativeChatComposer({
onChangeText(next)
}
const pickSuggestion = (suggestion: string): void => {
const pickSuggestion = (suggestion: ComposerSuggestion): void => {
if (!trigger) {
return
}
const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion)
const { text: nextText, cursor: nextCursor } = applyAutocomplete(
value,
trigger,
composerSuggestionInsertText(suggestion)
)
onChangeText(nextText)
setCursor(nextCursor)
setPendingSelection({ start: nextCursor, end: nextCursor })
@@ -128,7 +151,7 @@ export function MobileNativeChatComposer({
sendingRef.current = true
setSending(true)
try {
const accepted = await onSend(trimmed)
const accepted = await onSend(value.trimEnd())
if (accepted) {
setCursor(0)
}
@@ -141,21 +164,7 @@ export function MobileNativeChatComposer({
return (
<View>
{suggestions.length > 0 ? (
<View style={styles.suggestions}>
<ScrollView keyboardShouldPersistTaps="always" style={styles.suggestionScroll}>
{suggestions.map((s) => (
<Pressable
key={s}
style={({ pressed }) => [styles.suggestion, pressed && styles.suggestionPressed]}
onPress={() => pickSuggestion(s)}
>
<Text style={styles.suggestionText} numberOfLines={1}>
{s}
</Text>
</Pressable>
))}
</ScrollView>
</View>
<MobileNativeChatComposerSuggestions suggestions={suggestions} onPick={pickSuggestion} />
) : null}
{attachments.length > 0 ? (
<ScrollView
@@ -186,102 +195,95 @@ export function MobileNativeChatComposer({
))}
</ScrollView>
) : null}
<View style={styles.bar}>
{onAttachImage ? (
<Pressable
accessibilityLabel="Attach image"
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
onPress={onAttachImage}
disabled={isAttaching || disabled}
>
{isAttaching ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<TextInput
style={styles.input}
value={value}
onChangeText={handleChange}
// Controlled only transiently right after an autocomplete insert.
selection={pendingSelection ?? undefined}
onSelectionChange={(e) => {
setCursor(e.nativeEvent.selection.end)
setPendingSelection(null)
}}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
multiline
// Why: never revoke `editable` — iOS resigns first responder on a focused
// field, so a transient lock would yank the keyboard mid-typing (#10681).
// The lock gates sending; the draft survives and rides the next send.
textAlignVertical="top"
/>
{onMicPress ? (
<Pressable
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
onPress={dictationMode === 'hold' ? undefined : onMicPress}
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
disabled={disabled}
>
{micActive ? (
<Square
size={18}
color={colors.statusRed}
strokeWidth={2.4}
fill={colors.statusRed}
<View style={styles.composerInset} testID="native-chat-composer-inset">
<View style={styles.bar} testID="native-chat-composer">
<TextInput
style={styles.input}
value={value}
onChangeText={handleChange}
// Controlled only transiently right after an autocomplete insert.
selection={pendingSelection ?? undefined}
onSelectionChange={(e) => {
setCursor(e.nativeEvent.selection.end)
setPendingSelection(null)
}}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
multiline
// Why: never revoke `editable` — iOS resigns first responder on a focused
// field, so a transient lock would yank the keyboard mid-typing (#10681).
// The lock gates sending; the draft survives and rides the next send.
textAlignVertical="top"
/>
<View style={styles.actionRow} testID="native-chat-composer-actions">
{onAttachImage ? (
<Pressable
accessibilityLabel="Attach image"
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
onPress={onAttachImage}
disabled={isAttaching || disabled}
>
{isAttaching ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
{sessionOptions ? (
<MobileNativeChatSessionOptionPickers
{...sessionOptions}
sendInFlight={sending || isAttaching}
/>
) : (
<Mic size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<Pressable
accessibilityLabel="Send message"
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && canSend && styles.pressed
]}
onPress={handleSend}
disabled={!canSend}
>
<ArrowUp size={20} color={canSend ? colors.bgBase : colors.textMuted} strokeWidth={2.6} />
</Pressable>
) : null}
<View style={styles.actionSpacer} />
{onMicPress ? (
<Pressable
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
onPress={dictationMode === 'hold' ? undefined : onMicPress}
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
disabled={disabled}
>
{micActive ? (
<Square
size={18}
color={colors.statusRed}
strokeWidth={2.4}
fill={colors.statusRed}
/>
) : (
<Mic size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<Pressable
accessibilityLabel="Send message"
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && canSend && styles.pressed
]}
onPress={handleSend}
disabled={!canSend}
>
<ArrowUp
size={20}
color={canSend ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
</View>
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
suggestions: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
suggestionScroll: {
maxHeight: 180
},
suggestion: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
suggestionPressed: {
backgroundColor: colors.bgRaised
},
suggestionText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
attachmentStrip: {
maxHeight: 76,
borderTopWidth: StyleSheet.hairlineWidth,
@@ -321,18 +323,32 @@ const styles = StyleSheet.create({
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
composerInset: {
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.md
},
bar: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.sm,
gap: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
overflow: 'hidden'
},
actionRow: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
actionSpacer: {
flex: 1
},
input: {
flex: 1,
width: '100%',
maxHeight: 140,
minHeight: 40,
color: colors.textPrimary,
@@ -0,0 +1,82 @@
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands'
/** One row of the composer autocomplete: an agent slash command (with its
* catalog description, desktop parity) or a worktree file path. */
export type ComposerSuggestion =
| { kind: 'command'; command: SlashCommandSuggestion }
| { kind: 'file'; path: string }
export function composerSuggestionKey(suggestion: ComposerSuggestion): string {
return suggestion.kind === 'command'
? `command:${suggestion.command.name}`
: `file:${suggestion.path}`
}
/** The text the suggestion inserts at the trigger span. */
export function composerSuggestionInsertText(suggestion: ComposerSuggestion): string {
return suggestion.kind === 'command' ? `/${suggestion.command.name}` : `@${suggestion.path}`
}
export function MobileNativeChatComposerSuggestions({
suggestions,
onPick
}: {
suggestions: readonly ComposerSuggestion[]
onPick: (suggestion: ComposerSuggestion) => void
}): React.JSX.Element {
return (
<View style={styles.suggestions}>
<ScrollView keyboardShouldPersistTaps="always" style={styles.suggestionScroll}>
{suggestions.map((suggestion) => (
<Pressable
key={composerSuggestionKey(suggestion)}
accessibilityRole="button"
style={({ pressed }) => [styles.suggestion, pressed && styles.suggestionPressed]}
onPress={() => onPick(suggestion)}
>
<Text style={styles.suggestionText} numberOfLines={1}>
{composerSuggestionInsertText(suggestion)}
</Text>
{suggestion.kind === 'command' && suggestion.command.description ? (
<Text style={styles.suggestionDescription} numberOfLines={1}>
{suggestion.command.description}
</Text>
) : null}
</Pressable>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
suggestions: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
suggestionScroll: {
maxHeight: 220
},
suggestion: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
gap: 1
},
suggestionPressed: {
backgroundColor: colors.bgRaised
},
suggestionText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
suggestionDescription: {
color: colors.textSecondary,
fontSize: typography.metaSize
}
})
@@ -1,7 +1,8 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
vi.mock('react-native', async () => {
const React = await import('react')
@@ -30,7 +31,11 @@ function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
}
describe('MobileNativeChatMessage image-ref rendering', () => {
function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'a1', role: 'assistant', blocks, timestamp: null, source: 'transcript' }
}
describe('MobileNativeChatMessage', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
@@ -41,7 +46,10 @@ describe('MobileNativeChatMessage image-ref rendering', () => {
renderer = null
})
function render(message: NativeChatMessage): ReactTestRenderer {
function render(
message: NativeChatMessage,
props: { toolsExpanded?: boolean } = {}
): ReactTestRenderer {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
@@ -51,7 +59,7 @@ describe('MobileNativeChatMessage image-ref rendering', () => {
})
try {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message }))
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
} finally {
spy.mockRestore()
@@ -59,6 +67,9 @@ describe('MobileNativeChatMessage image-ref rendering', () => {
return renderer!
}
const textIn = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
it('renders a loadable preview URI as an image thumbnail', () => {
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
const image = tree.root.findByType('Image' as never)
@@ -84,4 +95,75 @@ describe('MobileNativeChatMessage image-ref rendering', () => {
.map((node) => String(node.children.join('')))
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})
it('labels a tool row with the target path instead of raw input JSON', () => {
const tree = render(
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
{ toolsExpanded: true }
)
const texts = textIn(tree.root)
expect(texts).toContain('src/index.ts')
expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false)
})
it('bounds expanded diff-less tool input before native text layout', () => {
const tree = render(
toolMessage([
{ type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } }
]),
{ toolsExpanded: true }
)
const detail = textIn(tree.root).find((text) => text.startsWith('{\n'))
expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1)
expect(detail?.endsWith('…')).toBe(true)
})
it('expands formatted detail for a collapsed JSON-string tool input', () => {
const tree = render(
toolMessage([
{
type: 'tool-call',
name: 'CustomTool',
input: '{"cmd":"git status","description":"Inspect changes"}'
}
])
)
const pressableWith = (label: string): ReactTestInstance =>
tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))!
act(() => pressableWith('1×').props.onPress())
// The row label is the command, and the detail stays closed until tapped.
expect(textIn(tree.root)).toContain('git status')
expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false)
act(() => pressableWith('CustomTool').props.onPress())
expect(textIn(tree.root)).toContain(
'{\n "cmd": "git status",\n "description": "Inspect changes"\n}'
)
})
it('does not echo the row label as detail when a row has nothing to expand', () => {
// The Tools toggle opens every row at once, bypassing the tap guard — a row
// whose formatted input is its own label would echo itself in a panel that
// no tap can dismiss.
const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), {
toolsExpanded: true
})
expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1)
// The chevron has to agree with the panel, or the row claims to be open over
// nothing and the tap that would close it is guarded off. Only the run header
// is open here; the row itself stays collapsed.
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
it('does not expand a plain input that already fits in the row label', () => {
const input = 'x'.repeat(60)
const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), {
toolsExpanded: true
})
expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1)
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
})
+17 -17
View File
@@ -14,12 +14,12 @@ import {
} from './mobile-native-chat-blocks'
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
import {
summarizeToolInput,
createToolInputDisplay,
summarizeToolRun,
toolFilePath
truncateToolDetail
} from './mobile-native-chat-tool-summary'
const MAX_VISIBLE_TOOL_PAIRS = 6
@@ -63,11 +63,7 @@ function ResultBody({
}
return (
<View style={[styles.toolResult, isError && styles.toolResultError]}>
<Text style={styles.mono}>
{output.length > MAX_TOOL_RESULT_CHARS
? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…`
: output}
</Text>
<Text style={styles.mono}>{truncateToolDetail(output)}</Text>
</View>
)
}
@@ -88,17 +84,21 @@ function ToolLine({
const [expanded, setExpanded] = useState(defaultExpanded)
const { call, result } = pair
const name = call ? call.name : 'Result'
const preview = call
? summarizeToolInput(call.input)
: (result?.output.split('\n')[0]?.slice(0, 80) ?? '')
const inputDisplay = call ? createToolInputDisplay(call.input) : null
const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? ''
// Why: collapsed tool rows are the common path; defer bounded diff parsing
// until the user asks to reveal the detail.
// and detail formatting until the user asks to reveal the detail.
const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null
const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null
const hasDetail = callDiff !== null || result !== undefined || preview.length > 40
const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined
const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true
// The group toggle opens every line at once, bypassing the tap guard, so the
// panel has to consult it too — else a detail-less row echoes its own label
// under itself and no tap can dismiss it.
const showDetail = hasDetail && expanded
// A tool that targets a file (Read/Edit/Write…) renders its preview as a
// tappable link that opens the file, independent of the line's expand tap.
const filePath = call ? toolFilePath(call.input) : null
const filePath = inputDisplay?.filePath ?? null
const openable = filePath !== null && onOpenFile !== undefined
return (
<View>
@@ -107,7 +107,7 @@ function ToolLine({
onPress={() => hasDetail && setExpanded((v) => !v)}
hitSlop={6}
>
{expanded ? (
{showDetail ? (
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
) : (
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
@@ -124,10 +124,10 @@ function ToolLine({
</Text>
) : null}
</Pressable>
{expanded ? (
{showDetail ? (
<View style={styles.toolDetail}>
{callDiff ? <DiffView lines={callDiff} /> : null}
{!callDiff && call && preview ? <Text style={styles.mono}>{preview}</Text> : null}
{callDetail ? <Text style={styles.mono}>{callDetail}</Text> : null}
{result ? (
<ResultBody output={result.output} isError={result.isError} diff={resultDiff} />
) : null}
@@ -68,6 +68,8 @@ export function MobileNativeChatOverlay({
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
askKey={controller.nativeChatAskKey}
onDismissAsk={controller.dismissNativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
question={controller.nativeChatQuestion}
@@ -96,6 +98,7 @@ export function MobileNativeChatOverlay({
onClearSendError={onClearSendError}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
sessionOptions={controller.nativeChatSessionOptions}
keyboardInset={keyboardInset}
/>
</View>
@@ -0,0 +1,244 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
import { MobileNativeChatSessionOptionPickers } from './MobileNativeChatSessionOptionPickers'
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Keyboard: { dismiss: vi.fn() },
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
X: 'X'
}))
vi.mock('../components/BottomDrawer', async () => {
const React = await import('react')
return {
BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) =>
visible ? React.createElement('BottomDrawer', { visible }, children) : null
}
})
const MODEL_DESCRIPTOR: SessionOptionDescriptor = {
id: 'model',
label: 'Model',
category: 'model',
kind: {
type: 'select',
currentValue: 'sonnet',
choices: [
{ value: 'sonnet', label: 'Sonnet 5' },
{ value: 'opus', label: 'Opus 4.8', description: 'Most capable' }
]
},
valueSource: 'reported',
settable: true
}
const EFFORT_DESCRIPTOR: SessionOptionDescriptor = {
id: 'effort',
label: 'Effort',
category: 'thought_level',
kind: {
type: 'select',
currentValue: 'high',
choices: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
valueSource: 'dispatched',
settable: true
}
const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = {
id: 'fastMode',
label: 'Fast mode',
category: 'mode',
kind: { type: 'boolean', currentValue: false },
valueSource: 'reported',
settable: true
}
describe('MobileNativeChatSessionOptionPickers', () => {
let renderer: ReactTestRenderer | null = null
const setOption = vi.fn<MobileNativeChatSessionOptionsController['setOption']>()
const invokeAction = vi.fn<MobileNativeChatSessionOptionsController['invokeAction']>()
const mount = (snapshot: SessionOptionDescriptor[], isWorking = false): void => {
const controller: MobileNativeChatSessionOptionsController = {
snapshot,
pendingId: null,
setOption,
invokeAction,
recordCommand: vi.fn()
}
act(() => {
renderer = create(
createElement(MobileNativeChatSessionOptionPickers, { controller, isWorking })
)
})
}
const pill = (
name: string
): {
props: {
onPress: () => void
accessibilityLabel?: string
disabled?: boolean
accessibilityRole?: string
accessibilityState?: { disabled?: boolean }
}
} =>
renderer!.root.find(
(node) =>
node.type === 'Pressable' &&
typeof node.props.accessibilityLabel === 'string' &&
node.props.accessibilityLabel.startsWith(name)
) as {
props: {
onPress: () => void
accessibilityLabel?: string
disabled?: boolean
accessibilityRole?: string
accessibilityState?: { disabled?: boolean }
}
}
const rowByText = (
text: string
): {
props: {
onPress: () => void
accessibilityRole?: string
accessibilityLabel?: string
accessibilityState?: { checked?: boolean; disabled?: boolean }
}
} => {
const label = renderer!.root
.findAll((node) => node.type === 'Text')
.find((node) => (node.props as { children?: unknown }).children === text)
if (!label) {
throw new Error(`No row labeled ${text}`)
}
let parent = label.parent
while (parent && parent.type !== 'Pressable') {
parent = parent.parent
}
if (!parent) {
throw new Error(`No pressable row for ${text}`)
}
return parent as unknown as {
props: {
onPress: () => void
accessibilityRole?: string
accessibilityLabel?: string
accessibilityState?: { checked?: boolean; disabled?: boolean }
}
}
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
setOption.mockReset()
setOption.mockResolvedValue(true)
invokeAction.mockReset()
invokeAction.mockResolvedValue(true)
})
afterEach(() => {
act(() => {
renderer?.unmount()
})
renderer = null
})
it('renders nothing without a model descriptor', () => {
mount([])
expect(renderer!.toJSON()).toBeNull()
})
it('shows the current model and effort in one pill', () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
expect(pill('Model').props).toMatchObject({
accessibilityLabel: 'Model, Sonnet 5 High',
disabled: false
})
const labels = renderer!.root
.findAll((node) => node.type === 'Text')
.map((node) => (node.props as { children?: unknown }).children)
expect(labels).toContain('Sonnet 5 High')
})
it('opens the model sheet and applies a picked model', async () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
await act(async () => pill('Model').props.onPress())
expect(renderer!.root.findByType('BottomDrawer').props.visible).toBe(true)
await act(async () => rowByText('Opus 4.8').props.onPress())
expect(setOption).toHaveBeenCalledWith('model', 'opus')
})
it('opens an option picker from the model sheet summary', async () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
await act(async () => pill('Model').props.onPress())
await act(async () => rowByText('Effort').props.onPress())
await act(async () => rowByText('Low').props.onPress())
expect(setOption).toHaveBeenCalledWith('effort', 'low')
})
it('shows absolute boolean values in option summaries', async () => {
mount([MODEL_DESCRIPTOR, FAST_MODE_DESCRIPTOR])
await act(async () => pill('Model').props.onPress())
expect(rowByText('Off').props.accessibilityLabel).toBe('Fast mode, Off')
})
it('announces choice selection and disabled state', async () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
expect(pill('Model').props).toMatchObject({
accessibilityRole: 'button',
accessibilityState: { disabled: false }
})
await act(async () => pill('Model').props.onPress())
expect(rowByText('Sonnet 5').props).toMatchObject({
accessibilityRole: 'radio',
accessibilityState: { checked: true, disabled: false }
})
expect(rowByText('Opus 4.8').props.accessibilityState?.checked).toBe(false)
})
it('closes without dispatch when re-picking the tracked value', async () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
await act(async () => pill('Model').props.onPress())
await act(async () => rowByText('Sonnet 5').props.onPress())
expect(setOption).not.toHaveBeenCalled()
})
it('renders agent-picker descriptors as a single action row', async () => {
mount([
{
...MODEL_DESCRIPTOR,
kind: { type: 'select', choices: [] },
valueSource: 'unknown',
action: { type: 'agent-picker' }
}
])
await act(async () => pill('Model').props.onPress())
expect(rowByText('Choose in agent picker…').props.accessibilityRole).toBe('button')
await act(async () => rowByText('Choose in agent picker…').props.onPress())
expect(invokeAction).toHaveBeenCalledWith('model')
})
it('locks the pills while the agent is working', () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true)
expect(pill('Model').props).toMatchObject({ disabled: true })
})
})
@@ -0,0 +1,206 @@
import { useState } from 'react'
import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronLeft, X } from 'lucide-react-native'
import { BottomDrawer } from '../components/BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type {
SessionOptionDescriptor,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import {
mobileModelPillLabel,
mobileOptionsPillLabel,
mobileSessionOptionSummaryValue,
mobileSessionOptionDisabledReason
} from './mobile-native-chat-session-option-labels'
import {
DescriptorRows,
Pill,
SessionOptionCaption,
SessionOptionSummaryRow
} from './MobileNativeChatSessionOptionRows'
import { sortNativeChatSessionOptions } from '../../../src/shared/native-chat-session-option-snapshot'
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
export type MobileNativeChatSessionOptionPickersProps = {
controller: MobileNativeChatSessionOptionsController
/** Pickers lock while the agent works — a mid-turn `/model` interleaves with
* the agent's own output (desktop parity). */
isWorking: boolean
/** A composer send owns the TUI input line until it settles. The host spaces a
* send's body and its Enter ~500ms apart, so an apply dispatched inside that
* window would be submitted as part of the user's prompt. The composer blocks
* the reverse direction on `pendingId`; this is the same guard mirrored. */
sendInFlight?: boolean
}
/** Combined model/session-option trigger and its mobile bottom drawer. */
export function MobileNativeChatSessionOptionPickers({
controller,
isWorking,
sendInFlight = false
}: MobileNativeChatSessionOptionPickersProps): React.JSX.Element | null {
const [openDescriptorId, setOpenDescriptorId] = useState<string | null>(null)
const { snapshot, pendingId } = controller
const model = snapshot.find((descriptor) => descriptor.category === 'model')
const options = sortNativeChatSessionOptions(snapshot)
if (!model) {
return null
}
const disabled = isWorking || pendingId !== null || sendInFlight
const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId)
const modelView = activeDescriptor?.id === model.id
const modelLabel = mobileModelPillLabel(model)
const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null
const pillLabel = optionsLabel ? `${modelLabel} ${optionsLabel}` : modelLabel
const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason)
const closePicker = (): void => setOpenDescriptorId(null)
const openPicker = (): void => {
Keyboard.dismiss()
setOpenDescriptorId(model.id)
}
const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
// Re-picking the tracked value is a no-op — never re-dispatch it.
if (
descriptor.valueSource !== 'unknown' &&
descriptor.kind.type === 'select' &&
descriptor.kind.currentValue === value
) {
closePicker()
return
}
void controller.setOption(descriptor.id, value).then((applied) => {
if (applied) {
closePicker()
}
})
}
const invokeAction = (descriptor: SessionOptionDescriptor): void => {
void controller.invokeAction(descriptor.id).then((invoked) => {
if (invoked) {
closePicker()
}
})
}
return (
<View>
<Pill
label={pillLabel}
accessibleName={`Model, ${pillLabel}`}
disabled={disabled}
onPress={openPicker}
/>
<BottomDrawer visible={activeDescriptor !== undefined} onClose={closePicker}>
{activeDescriptor ? (
<View style={styles.sheet}>
<View style={styles.sheetHeader}>
<Pressable
accessibilityLabel={modelView ? 'Close picker' : 'Back to models'}
accessibilityRole="button"
style={({ pressed }) => [styles.sheetNav, pressed && styles.pressed]}
onPress={modelView ? closePicker : () => setOpenDescriptorId(model.id)}
hitSlop={8}
>
{modelView ? (
<X size={18} color={colors.textSecondary} strokeWidth={2.2} />
) : (
<ChevronLeft size={18} color={colors.textSecondary} strokeWidth={2.2} />
)}
</Pressable>
<Text style={styles.sheetTitle}>
{modelView ? 'Select model' : `Select ${activeDescriptor.label.toLowerCase()}`}
</Text>
<View style={styles.sheetHeaderSide}>
{pendingId !== null ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</View>
</View>
{activeDescriptor.valueSource === 'dispatched' ? (
<SessionOptionCaption>Sent to the agent — not confirmed</SessionOptionCaption>
) : null}
{reason ? <SessionOptionCaption>{reason}</SessionOptionCaption> : null}
<View style={styles.choiceGroup}>
<DescriptorRows
descriptor={activeDescriptor}
disabled={disabled}
grouped
onSetOption={(value) => applyOption(activeDescriptor, value)}
onInvokeAction={() => invokeAction(activeDescriptor)}
/>
</View>
{modelView && options.length > 0 ? (
<View style={styles.optionGroup}>
{options.map((descriptor, index) => (
<SessionOptionSummaryRow
key={descriptor.id}
label={descriptor.label}
value={mobileSessionOptionSummaryValue(descriptor)}
disabled={disabled}
divided={index < options.length - 1}
onPress={() => setOpenDescriptorId(descriptor.id)}
/>
))}
</View>
) : null}
</View>
) : null}
</BottomDrawer>
</View>
)
}
const styles = StyleSheet.create({
sheet: {
paddingBottom: spacing.xs
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.lg
},
sheetTitle: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '700',
textAlign: 'center'
},
sheetNav: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
sheetHeaderSide: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center'
},
choiceGroup: {
overflow: 'hidden',
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
optionGroup: {
overflow: 'hidden',
marginTop: spacing.md,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
pressed: {
opacity: 0.7
}
})
@@ -0,0 +1,345 @@
// The pill and choice-row primitives the session-option card is built from, kept
// beside it so the card file stays about layout and apply wiring.
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Check, ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type {
SessionOptionDescriptor,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
/** Muted one-liner above a group — dispatch state, or why a row is locked. */
export function SessionOptionCaption({ children }: { children: string }): React.JSX.Element {
return <Text style={styles.caption}>{children}</Text>
}
export function Pill({
label,
accessibleName,
disabled,
onPress
}: {
label: string
accessibleName: string
disabled: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityLabel={accessibleName}
accessibilityRole="button"
accessibilityState={{ disabled }}
style={({ pressed }) => [styles.pill, pressed && !disabled && styles.pressed]}
onPress={onPress}
disabled={disabled}
hitSlop={6}
>
<Text
style={[styles.pillText, disabled && styles.pillTextDisabled]}
numberOfLines={1}
ellipsizeMode="tail"
>
{label}
</Text>
<ChevronDown size={12} color={disabled ? colors.textMuted : colors.textSecondary} />
</Pressable>
)
}
function ChoiceRow({
label,
description,
selected,
disabled,
grouped,
divided,
onPress
}: {
label: string
description?: string
selected: boolean
disabled: boolean
grouped: boolean
divided: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityRole="radio"
accessibilityState={{ checked: selected, disabled }}
style={[
styles.row,
selected && styles.rowSelected,
grouped && styles.rowGrouped,
divided && styles.rowDivided,
disabled && styles.rowDisabled
]}
onPress={onPress}
disabled={disabled}
>
<View style={[styles.radio, selected && styles.radioOn]}>
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
<View style={styles.rowBody}>
<Text style={styles.rowLabel}>{label}</Text>
{description ? (
<Text style={styles.rowDescription} numberOfLines={2}>
{description}
</Text>
) : null}
</View>
</Pressable>
)
}
function ActionRow({
label,
disabled,
grouped,
onPress
}: {
label: string
disabled: boolean
grouped: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled }}
style={[styles.row, grouped && styles.rowGrouped, disabled && styles.rowDisabled]}
onPress={onPress}
disabled={disabled}
>
<View style={styles.rowBody}>
<Text style={styles.rowLabel}>{label}</Text>
</View>
</Pressable>
)
}
export function SessionOptionSummaryRow({
label,
value,
disabled,
divided,
onPress
}: {
label: string
value: string
disabled: boolean
divided: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityLabel={`${label}, ${value}`}
accessibilityRole="button"
accessibilityState={{ disabled }}
style={({ pressed }) => [
styles.summaryRow,
divided && styles.rowDivided,
pressed && !disabled && styles.pressed,
disabled && styles.rowDisabled
]}
onPress={onPress}
disabled={disabled}
>
<Text style={styles.summaryLabel}>{label}</Text>
<Text style={styles.summaryValue} numberOfLines={1}>
{value}
</Text>
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
)
}
export function DescriptorRows({
descriptor,
disabled,
grouped = false,
onSetOption,
onInvokeAction
}: {
descriptor: SessionOptionDescriptor
disabled: boolean
grouped?: boolean
onSetOption: (value: SessionOptionValue) => void
onInvokeAction: () => void
}): React.JSX.Element {
const locked = disabled || !descriptor.settable
// Why: flip-only without a baseline is an action — never claim On/Off.
if (descriptor.action?.type === 'toggle-command') {
return (
<ActionRow
label={`Toggle ${descriptor.label.toLowerCase()}`}
disabled={locked}
grouped={grouped}
onPress={onInvokeAction}
/>
)
}
// Why: agent-picker opens the TUI; it is not a set of radio choices.
if (descriptor.action?.type === 'agent-picker') {
return (
<ActionRow
label="Choose in agent picker…"
disabled={locked}
grouped={grouped}
onPress={onInvokeAction}
/>
)
}
// Unknown booleans leave both radios unselected instead of inventing truth.
if (descriptor.kind.type === 'boolean') {
const current = descriptor.kind.currentValue
return (
<>
{current === undefined ? (
<SessionOptionCaption>Current value unknown — pick On or Off</SessionOptionCaption>
) : null}
<ChoiceRow
label="On"
selected={current === true}
disabled={locked}
grouped={grouped}
divided={grouped}
onPress={() => onSetOption(true)}
/>
<ChoiceRow
label="Off"
selected={current === false}
disabled={locked}
grouped={grouped}
divided={false}
onPress={() => onSetOption(false)}
/>
</>
)
}
const { currentValue, choices } = descriptor.kind
return (
<>
{choices.map((choice, index) => (
<ChoiceRow
key={choice.value}
label={choice.label}
description={choice.description}
selected={choice.value === currentValue}
disabled={locked}
grouped={grouped}
divided={grouped && index < choices.length - 1}
onPress={() => onSetOption(choice.value)}
/>
))}
</>
)
}
const styles = StyleSheet.create({
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
maxWidth: 180,
minHeight: 28,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
pillText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600',
flexShrink: 1
},
pillTextDisabled: {
color: colors.textMuted
},
pressed: {
opacity: 0.7
},
caption: {
color: colors.textMuted,
fontSize: typography.metaSize,
paddingHorizontal: spacing.md,
paddingBottom: spacing.xs
},
row: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
minHeight: 44,
alignItems: 'center',
borderRadius: radii.card,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.xs
},
rowSelected: {
borderColor: colors.statusGreen
},
rowGrouped: {
marginBottom: 0,
borderWidth: 0,
borderRadius: 0,
backgroundColor: 'transparent'
},
rowDivided: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
rowDisabled: {
opacity: 0.5
},
radio: {
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center'
},
radioOn: {
backgroundColor: colors.statusGreen,
borderColor: colors.statusGreen
},
rowBody: {
flex: 1,
gap: 2
},
rowLabel: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
rowDescription: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
summaryRow: {
minHeight: 48,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
summaryLabel: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
summaryValue: {
maxWidth: 160,
color: colors.textSecondary,
fontSize: typography.bodySize
}
})
+19 -9
View File
@@ -19,11 +19,11 @@ import {
mobileNativeChatEmptyState,
type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
@@ -82,11 +82,18 @@ type Props = {
onClearSendError?: () => void
filePaths?: string[]
onNeedFiles?: (query: string) => void
/** Model/session-option pickers for the composer action row (desktop parity). */
sessionOptions?: MobileNativeChatSessionOptionPickersProps | null
/** A pending agent question/permission detected from live status, shown as a
* native card above the composer; answering sends text to the agent. */
/** Structured AskUserQuestion prompt parsed from the transcript (preferred over
* the heuristic question card). */
ask?: AskPrompt | null
/** Stable key for the ask card. Dismissal state lives in the controller (it
* must survive this subtree unmounting on a chat↔terminal toggle). */
askKey?: string | null
/** Hide the answered/dismissed ask until a different question arrives. */
onDismissAsk?: () => void
/** Deliver the ask answer as per-question selections; the send hook turns them
* into selector keystrokes (Claude) or pasted label text (other agents). */
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
@@ -132,7 +139,10 @@ export function MobileNativeChatView({
onClearSendError,
filePaths,
onNeedFiles,
sessionOptions,
ask,
askKey,
onDismissAsk,
onAnswerAsk,
onCancelAsk,
question,
@@ -145,10 +155,6 @@ export function MobileNativeChatView({
const insets = useSafeAreaInsets()
const listRef = useRef<FlatList<NativeChatMessage>>(null)
const [toolsExpanded, setToolsExpanded] = useState(false)
// Dismiss the question card as soon as it's answered; the live status lingers
// briefly (the agent emits a post-tool event with the same prompt), so hide it
// until a genuinely different question arrives.
const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask)
// Lift the composer clear of the keyboard, plus the bottom safe-area so it
// never sits under the home indicator / nav bar (mirrors the terminal dock).
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
@@ -338,22 +344,24 @@ export function MobileNativeChatView({
</GestureHandlerRootView>
)}
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
heuristic permission, then a heuristic question. */}
{showAsk && ask ? (
heuristic permission, then a heuristic question. The controller owns
dismissal (it must survive this subtree unmounting on a view toggle);
`ask` arrives already nulled while dismissed. */}
{ask ? (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
if (accepted) {
dismissAsk()
onDismissAsk?.()
}
return accepted
}}
onCancel={async () => {
const accepted = (await onCancelAsk?.()) ?? false
if (accepted) {
dismissAsk()
onDismissAsk?.()
}
return accepted
}}
@@ -415,6 +423,8 @@ export function MobileNativeChatView({
value={composerText}
onChangeText={onComposerTextChange}
onSend={handleSend}
agent={agent}
sessionOptions={sessionOptions}
onAttachImage={onAttachImage}
attachments={attachments}
onRemoveAttachment={onRemoveAttachment}
@@ -64,7 +64,12 @@ describe('openMobileFileTap', () => {
expect(client.sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: '/tmp/result.json', terminal: 'terminal-1' },
{
worktree: 'id:wt-1',
pathText: '/tmp/result.json',
terminal: 'terminal-1',
crossWorkspace: true
},
{ timeoutMs: 10_000 }
)
expect(pushPreviewRoute).toHaveBeenCalledWith({
@@ -322,7 +327,13 @@ describe('openMobileFileTap', () => {
expect(client.sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'term-1', cwd: '/repo/src' },
{
worktree: 'id:wt-1',
pathText: 'index.ts',
terminal: 'term-1',
cwd: '/repo/src',
crossWorkspace: true
},
{ timeoutMs: 10_000 }
)
})
@@ -75,6 +75,8 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
{
worktree,
pathText: options.pathText,
// Why: opts into sibling-workspace resolutions; this caller honors resolved.worktree.
crossWorkspace: true,
...(options.terminalHandle && options.terminalHandle.trim().length > 0
? { terminal: options.terminalHandle }
: {}),
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSlashCommandSuggestions,
rankSuggestions
} from './mobile-native-chat-autocomplete'
@@ -57,3 +58,36 @@ describe('rankSuggestions', () => {
expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b'])
})
})
describe('rankSlashCommandSuggestions', () => {
const commands = [
{ name: 'clear', description: 'Clear conversation history' },
{ name: 'compact', description: 'Summarize and compact' },
{ name: 'mcp', description: 'List MCP tools' }
]
it('shows the whole catalog for a bare slash', () => {
expect(rankSlashCommandSuggestions(commands, '').map((c) => c.name)).toEqual([
'clear',
'compact',
'mcp'
])
})
it('ranks prefix matches ahead of substring matches', () => {
expect(rankSlashCommandSuggestions(commands, 'c').map((c) => c.name)).toEqual([
'clear',
'compact',
'mcp'
])
expect(rankSlashCommandSuggestions(commands, 'm').map((c) => c.name)).toEqual([
'mcp',
'compact'
])
})
it('is case-insensitive and drops non-matches', () => {
expect(rankSlashCommandSuggestions(commands, 'CLE').map((c) => c.name)).toEqual(['clear'])
expect(rankSlashCommandSuggestions(commands, 'zzz')).toEqual([])
})
})
@@ -2,6 +2,8 @@
// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's
// unit-testable and the composer stays a thin view over it.
import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands'
export type AutocompleteKind = 'file' | 'slash'
export type AutocompleteTrigger = {
@@ -91,3 +93,31 @@ export function rankSuggestions(candidates: readonly string[], query: string, li
}
return [...prefix, ...substring].slice(0, limit)
}
/** Rank an agent's slash commands: prefix matches first, then substring, in
* catalog order. A bare `/` shows the whole catalog (desktop parity) — the
* suggestion list scrolls, so the cap only guards against absurd catalogs. */
export function rankSlashCommandSuggestions(
commands: readonly SlashCommandSuggestion[],
query: string,
limit = 50
): SlashCommandSuggestion[] {
const q = query.toLowerCase()
if (q.length === 0) {
return commands.slice(0, limit)
}
const prefix: SlashCommandSuggestion[] = []
const substring: SlashCommandSuggestion[] = []
for (const command of commands) {
const lower = command.name.toLowerCase()
if (lower.startsWith(q)) {
prefix.push(command)
} else if (lower.includes(q)) {
substring.push(command)
}
if (prefix.length >= limit) {
break
}
}
return [...prefix, ...substring].slice(0, limit)
}
@@ -3,7 +3,6 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const TEXT_SIZE = 17
export const MONO_SIZE = 12
export const MAX_TOOL_RESULT_CHARS = 4000
export const styles = StyleSheet.create({
row: {
@@ -60,7 +60,7 @@ describe('openMobileNativeChatFileTap', () => {
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'src/app.ts' },
{ worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true },
{ timeoutMs: 10_000 }
)
})
@@ -74,7 +74,7 @@ describe('openMobileNativeChatFileTap', () => {
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'src/app.ts' },
{ worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true },
{ timeoutMs: 10_000 }
)
expect(options.triggerOpenFeedback).toHaveBeenCalledTimes(1)
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
describe('classifyMobileNativeChatSend', () => {
it('classifies catalog commands per agent', () => {
expect(classifyMobileNativeChatSend('claude', '/clear')).toBe('command')
expect(classifyMobileNativeChatSend('claude', '/compact')).toBe('command')
expect(classifyMobileNativeChatSend('codex', '/model')).toBe('command')
expect(classifyMobileNativeChatSend('codex', '/permissions')).toBe('command')
})
it('treats slash tokens outside the agent catalog as unknown, never chat', () => {
// `/model` is not a verified Claude command — it still dispatches to the
// TUI, so it must not get a chat bubble, but it can't claim a command ran.
expect(classifyMobileNativeChatSend('claude', '/model sonnet')).toBe('unknown-token')
expect(classifyMobileNativeChatSend('claude', '/cost')).toBe('unknown-token')
expect(classifyMobileNativeChatSend('claude', '/diff')).toBe('unknown-token')
})
it('keeps prose as chat, including leading-whitespace slash text', () => {
expect(classifyMobileNativeChatSend('claude', 'hello there')).toBe('chat')
expect(classifyMobileNativeChatSend('claude', ' /clear is a command')).toBe('chat')
expect(classifyMobileNativeChatSend('claude', '/usr/bin/python is missing')).toBe(
'unknown-token'
)
})
it('treats $ tokens as skill grammar only for Codex', () => {
expect(classifyMobileNativeChatSend('codex', '$deploy now')).toBe('unknown-token')
expect(classifyMobileNativeChatSend('claude', '$PATH is empty')).toBe('chat')
})
it('defaults to chat when no agent is resolved', () => {
expect(classifyMobileNativeChatSend(null, '/clear')).toBe('chat')
})
})
@@ -0,0 +1,34 @@
// Mobile counterpart of desktop's send classification gate
// (src/renderer/src/components/native-chat/NativeChatComposer.tsx): slash/skill
// sends are TUI control actions, not chat turns — they never echo as a user
// bubble, because the transcript will never contain a matching user turn and
// the optimistic echo would sit at "Queued" forever.
import {
getNativeChatAgentProfile,
getVerifiedNativeChatCommands
} from '../../../src/shared/native-chat-agent-profiles'
import {
classifyNativeChatSend,
type NativeChatSendClassification
} from '../../../src/shared/native-chat-slash-commands'
export type { NativeChatSendClassification }
/** Classify a mobile chat send for the tab's agent. Mobile has no skill picker,
* so there is never a picker-origin token that reclassifies a `/token` as chat. */
export function classifyMobileNativeChatSend(
agent: string | null,
text: string
): NativeChatSendClassification {
if (!agent) {
return 'chat'
}
const profile = getNativeChatAgentProfile(agent)
return classifyNativeChatSend(
text,
getVerifiedNativeChatCommands(agent),
null,
profile?.skillPrefix ?? null
)
}
@@ -0,0 +1,79 @@
// Mobile port of desktop's pill labeling
// (src/renderer/src/components/native-chat/native-chat-session-option-labels.ts),
// minus i18n — mobile renders plain strings throughout.
import type {
SessionOptionDescriptor,
SessionOptionDisabledReason,
SessionOptionSelectChoice
} from '../../../src/shared/native-chat-session-options'
export function mobileSessionOptionDisabledReason(
reason: SessionOptionDisabledReason | undefined
): string | null {
// Exhaustive over SessionOptionDisabledReason so new keys are a compile error.
switch (reason) {
case 'set-when-session-starts':
return 'Set when the session starts.'
case 'available-after-session-start':
return 'Available after the session starts.'
case undefined:
return null
}
}
function selectedChoiceLabel(descriptor: SessionOptionDescriptor): string | null {
if (
descriptor.valueSource === 'unknown' ||
descriptor.kind.type !== 'select' ||
!descriptor.kind.currentValue
) {
return null
}
const current = descriptor.kind.currentValue
const choice: SessionOptionSelectChoice = descriptor.kind.choices.find(
(candidate) => candidate.value === current
) ?? { value: current, label: current }
return choice.label
}
/** Value-only pill text — the category lives on the sheet title, not the pill. */
export function mobileModelPillLabel(descriptor: SessionOptionDescriptor): string {
return selectedChoiceLabel(descriptor) ?? 'Model'
}
export function mobileSessionOptionSummaryValue(descriptor: SessionOptionDescriptor): string {
if (descriptor.valueSource === 'unknown') {
return 'Not set'
}
if (descriptor.kind.type === 'select') {
return selectedChoiceLabel(descriptor) ?? 'Not set'
}
return descriptor.kind.currentValue === undefined
? 'Not set'
: descriptor.kind.currentValue
? 'On'
: 'Off'
}
export function mobileOptionsPillLabel(descriptors: readonly SessionOptionDescriptor[]): string {
const labels: string[] = []
for (const descriptor of descriptors) {
if (descriptor.valueSource === 'unknown') {
continue
}
if (descriptor.kind.type === 'select') {
const label = selectedChoiceLabel(descriptor)
if (label) {
labels.push(label)
}
} else if (descriptor.kind.currentValue === true) {
labels.push(descriptor.id === 'fastMode' ? 'Fast' : descriptor.label)
}
}
if (labels.length > 0) {
return labels.join(' · ')
}
const effort = descriptors.find((descriptor) => descriptor.id === 'effort')
return effort ? effort.label : 'Options'
}
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary'
import {
briefToolArg,
describeToolInput,
summarizeToolInput,
toolFilePath
} from './mobile-native-chat-tool-summary'
describe('summarizeToolInput', () => {
it('passes short strings through, collapsing whitespace', () => {
@@ -53,6 +58,13 @@ describe('toolFilePath', () => {
})
})
describe('describeToolInput', () => {
it('is re-exported and labels rows with the path or primary argument', () => {
expect(describeToolInput({ file_path: 'src/a.ts', offset: 3 })).toBe('src/a.ts')
expect(describeToolInput('{"cmd":"git status"}')).toBe('git status')
})
})
describe('briefToolArg', () => {
it('takes the basename of a forward-slash path', () => {
expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts')
@@ -1,7 +1,13 @@
export {
briefToolArg,
countToolCalls,
createToolInputDisplay,
describeToolInput,
formatToolInput,
isStructuredToolInput,
MAX_TOOL_DETAIL_LENGTH,
summarizeToolInput,
summarizeToolRun,
toolFilePath
toolFilePath,
truncateToolDetail
} from '../../../src/shared/native-chat-tool-summary'
@@ -106,7 +106,19 @@ describe('mobile session route', () => {
expect(end).toBeGreaterThan(start)
const resumeCard = homeSource.slice(start, end)
expect(resumeCard).toContain('openMobileSession({')
expect(resumeCard).toContain('openResume(')
expect(resumeCard).not.toContain('router.push(')
// The tap handler itself must go through the coordinated transition; its only
// direct push is the shallow noticed host-index route for a proven-missing target.
const handlerStart = homeSource.indexOf('const openResume = useCallback(')
const handlerEnd = homeSource.indexOf('[openMobileSession, router]', handlerStart)
expect(handlerStart).toBeGreaterThanOrEqual(0)
expect(handlerEnd).toBeGreaterThan(handlerStart)
const openResume = homeSource.slice(handlerStart, handlerEnd)
expect(openResume).toContain('openMobileSession({')
expect(openResume.match(/router\.push\(/g)).toHaveLength(1)
expect(openResume).toContain('router.push(hostRouteWithNotice(')
})
})
@@ -0,0 +1,8 @@
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
/** Route ids that name no managed worktree, so the host can never list or resolve them.
* Every "is this workspace still there?" check must exempt them, or their permanent
* absence from the catalog reads as a deletion. */
export function isSyntheticWorkspaceRoute(worktreeId: string): boolean {
return worktreeId.startsWith('folder:') || isFloatingWorkspaceWorktreeId(worktreeId)
}
@@ -175,7 +175,7 @@ describe('useLiveWorktreeName request volume', () => {
connState: 'connected',
routeName: undefined,
worktreeId: 'global-floating-terminal'
})
}).name
return null
}
@@ -202,7 +202,7 @@ describe('useLiveWorktreeName request volume', () => {
let renderer: ReactTestRenderer | null = null
function RouteHarness(props: { routeName?: string; worktreeId: string }): null {
const name = useLiveWorktreeName({
const { name } = useLiveWorktreeName({
client,
connState: 'connected',
routeName: props.routeName,
@@ -248,4 +248,62 @@ describe('useLiveWorktreeName request volume', () => {
expect(firstNameByWorktree.get('global-floating-terminal')).toBe('Floating Workspace')
expect(firstNameByWorktree.get('repo-2::/worktree')).toBe('Next workspace')
})
// The bounce in use-missing-worktree-bounce.ts rides this poll rather than adding a second
// RPC, so the failure branch has to publish a verdict instead of returning early.
it('reports the host-proven verdict from the same poll', async () => {
let resolution = ''
function VerdictHarness(): null {
resolution = useLiveWorktreeName({
client,
connState: 'connected',
routeName: 'Route name',
worktreeId: 'repo-1::/worktree'
}).resolution
return null
}
const mount = async (): Promise<void> => {
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(VerdictHarness))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
}
await mount()
expect(resolution).toBe('present')
act(() => renderer?.unmount())
renderer = null
sendRequest.mockResolvedValue({
id: 'worktree-show',
ok: false,
error: { code: 'selector_not_found', message: 'Selector not found' },
_meta: { runtimeId: 'runtime-1' }
})
await mount()
// Why: a transient desktop repo-scan rejection also answers selector_not_found,
// so one miss stays unproven; only the confirming poll may say 'missing'.
expect(resolution).toBe('unknown')
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000)
})
expect(resolution).toBe('missing')
act(() => renderer?.unmount())
renderer = null
// A dropped socket is not a deletion, so it must leave the verdict unproven.
sendRequest.mockResolvedValue({
id: 'worktree-show',
ok: false,
error: { code: 'runtime_busy', message: 'Runtime busy' },
_meta: { runtimeId: 'runtime-1' }
})
await mount()
expect(resolution).toBe('unknown')
})
})
+59 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFocusEffect } from 'expo-router'
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id'
@@ -6,6 +6,10 @@ import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name'
import { FLOATING_WORKSPACE_TITLE, isFloatingWorkspaceWorktreeId } from './floating-workspace'
import {
classifyWorktreeShowResponse,
type WorktreeShowResolution
} from '../worktree/worktree-show-resolution'
const WORKTREE_NAME_FALLBACK_POLL_MS = 3000
@@ -16,7 +20,19 @@ type Params = {
worktreeId: string
}
export function useLiveWorktreeName({ client, connState, routeName, worktreeId }: Params): string {
export type LiveWorktreeName = {
name: string
/** What the host last proved about this worktree still existing — see the bounce in
* use-missing-worktree-bounce.ts. */
resolution: WorktreeShowResolution
}
export function useLiveWorktreeName({
client,
connState,
routeName,
worktreeId
}: Params): LiveWorktreeName {
// Why: the floating sentinel has no worktree record, so worktree.show would
// fail forever and keep the 3s fallback poll alive; its title is fixed.
const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId)
@@ -25,6 +41,15 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
worktreeId,
name: routeNameHint
}))
// Why: keyed by id so a verdict about the previous route can never survive into the next one.
const [resolved, setResolved] = useState<{
worktreeId: string
resolution: WorktreeShowResolution
}>(() => ({ worktreeId, resolution: 'unknown' }))
// Why: a transient desktop repo-scan rejection collapses the catalog to zero rows and
// answers selector_not_found for a live worktree — one miss is suspicion, not proof.
// The fallback poll guarantees a confirming read (a failed show never stops it).
const missingStreakRef = useRef({ worktreeId, count: 0 })
useEffect(() => {
setWorktreeName((current) =>
@@ -34,6 +59,12 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
)
}, [routeNameHint, worktreeId])
useEffect(() => {
setResolved((current) =>
current.worktreeId === worktreeId ? current : { worktreeId, resolution: 'unknown' }
)
}, [worktreeId])
useFocusEffect(
useCallback(() => {
if (isFloatingWorkspace || !client || connState !== 'connected') {
@@ -60,7 +91,26 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
const response = await client.sendRequest('worktree.show', {
worktree: `id:${worktreeId}`
})
if (stale || generation !== refreshGeneration || !response.ok) {
if (stale || generation !== refreshGeneration) {
return
}
const classified = classifyWorktreeShowResponse(response)
const streak = missingStreakRef.current
missingStreakRef.current = {
worktreeId,
count:
classified === 'missing'
? (streak.worktreeId === worktreeId ? streak.count : 0) + 1
: 0
}
const resolution =
classified === 'missing' && missingStreakRef.current.count < 2 ? 'unknown' : classified
setResolved((current) =>
current.worktreeId === worktreeId && current.resolution === resolution
? current
: { worktreeId, resolution }
)
if (!response.ok) {
return
}
const result = (response as RpcSuccess).result as {
@@ -148,7 +198,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
)
if (isFloatingWorkspace) {
return FLOATING_WORKSPACE_TITLE
// The sentinel has no worktree record to resolve, so nothing is ever proven about it.
return { name: FLOATING_WORKSPACE_TITLE, resolution: 'unknown' }
}
return {
name: worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint,
resolution: resolved.worktreeId === worktreeId ? resolved.resolution : 'unknown'
}
return worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint
}
@@ -0,0 +1,79 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeShowResolution } from '../worktree/worktree-show-resolution'
import { isSyntheticWorkspaceRoute } from './synthetic-workspace-route'
import {
shouldBounceMissingWorktree,
useMissingWorktreeBounce
} from './use-missing-worktree-bounce'
describe('shouldBounceMissingWorktree', () => {
it('bounces only a host-proven missing worktree', () => {
expect(shouldBounceMissingWorktree('repo::wt', 'missing')).toBe(true)
expect(shouldBounceMissingWorktree('repo::wt', 'unknown')).toBe(false)
expect(shouldBounceMissingWorktree('repo::wt', 'present')).toBe(false)
})
it('never bounces synthetic routes the host cannot resolve', () => {
expect(isSyntheticWorkspaceRoute('folder:/Users/x/dir')).toBe(true)
expect(shouldBounceMissingWorktree('folder:/Users/x/dir', 'missing')).toBe(false)
expect(shouldBounceMissingWorktree('global-floating-terminal', 'missing')).toBe(false)
})
})
describe('useMissingWorktreeBounce', () => {
let renderer: ReactTestRenderer | null = null
const bounce = vi.fn()
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
bounce.mockReset()
renderer = null
})
function Harness(props: { worktreeId: string; resolution: WorktreeShowResolution }): null {
useMissingWorktreeBounce({
hostId: 'host-1',
worktreeId: props.worktreeId,
resolution: props.resolution,
bounce
})
return null
}
function render(worktreeId: string, resolution: WorktreeShowResolution): void {
act(() => {
const element = createElement(Harness, { worktreeId, resolution })
if (renderer) {
renderer.update(element)
} else {
renderer = create(element)
}
})
}
it('bounces exactly once per worktree even across re-renders', () => {
render('repo::wt', 'unknown')
expect(bounce).not.toHaveBeenCalled()
render('repo::wt', 'missing')
expect(bounce).toHaveBeenCalledExactlyOnceWith('host-1')
// Why: navigation lands after the render, so the pre-unmount renders must not re-fire.
render('repo::wt', 'missing')
expect(bounce).toHaveBeenCalledTimes(1)
act(() => renderer?.unmount())
})
it('re-arms for a different worktree on the reused screen', () => {
render('repo::wt-1', 'missing')
expect(bounce).toHaveBeenCalledTimes(1)
render('repo::wt-2', 'unknown')
expect(bounce).toHaveBeenCalledTimes(1)
render('repo::wt-2', 'missing')
expect(bounce).toHaveBeenCalledTimes(2)
act(() => renderer?.unmount())
})
})
@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react'
import { isSyntheticWorkspaceRoute } from './synthetic-workspace-route'
import type { WorktreeShowResolution } from '../worktree/worktree-show-resolution'
export function shouldBounceMissingWorktree(
worktreeId: string,
resolution: WorktreeShowResolution
): boolean {
return resolution === 'missing' && !isSyntheticWorkspaceRoute(worktreeId)
}
/** Sends the route back to the host index once the host has *proven* the worktree is gone —
* a workspace deleted on the desktop while the phone held the link (Resume, a notification,
* a cold deep link) otherwise lands on a session screen whose every RPC fails. */
export function useMissingWorktreeBounce(args: {
hostId: string
worktreeId: string
resolution: WorktreeShowResolution
bounce: (hostId: string) => void
}): void {
const { hostId, worktreeId, resolution } = args
// Why: navigation takes effect after this render, so without a latch the renders before
// unmount would each fire again — and it lets callers pass an inline bounce closure.
const bouncedRef = useRef<string | null>(null)
const bounceRef = useRef(args.bounce)
// Why: synced in an effect (render must stay pure); declared first so the
// bounce effect below always sees the freshest closure in the same commit.
useEffect(() => {
bounceRef.current = args.bounce
})
useEffect(() => {
if (!hostId || bouncedRef.current === worktreeId) {
return
}
if (!shouldBounceMissingWorktree(worktreeId, resolution)) {
return
}
bouncedRef.current = worktreeId
bounceRef.current(hostId)
}, [hostId, worktreeId, resolution])
}
@@ -0,0 +1,133 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type { ReviewScreenState } from './mobile-diff-review-screen-model'
import { useMobileDiffReviewController } from './use-mobile-diff-review-controller'
const loadSnapshot = vi.hoisted(() => vi.fn())
vi.mock('./mobile-diff-review-loaders', () => ({
loadMobileDiffReviewSnapshot: loadSnapshot,
loadMobileDiffReviewDiff: vi.fn().mockResolvedValue({ kind: 'idle' })
}))
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-haptics', () => ({
impactAsync: vi.fn(),
notificationAsync: vi.fn(),
selectionAsync: vi.fn(),
performAndroidHapticsAsync: vi.fn(),
AndroidHaptics: {},
ImpactFeedbackStyle: {},
NotificationFeedbackType: {}
}))
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
const client = { sendRequest: vi.fn() } as unknown as RpcClient
function readySnapshot(branch: string): ReviewScreenState {
return {
kind: 'ready',
status: { entries: [], branch, head: 'abc123' },
comments: [],
reviewState: { reviewedKeys: [] },
branchCompare: null
} as unknown as ReviewScreenState
}
describe('useMobileDiffReviewController', () => {
let renderer: ReactTestRenderer | null = null
let screenState: ReviewScreenState = { kind: 'loading' }
function Probe({ connState }: { connState: ConnectionState }): null {
const controller = useMobileDiffReviewController({
client,
connState,
hostId: 'host-1',
worktreeId: 'wt-1',
name: 'review',
initialFilter: 'all',
initialTarget: null,
onOpenSession: () => {},
onReconnect: () => {}
})
screenState = controller.screenState
return null
}
async function update(connState: ConnectionState): Promise<void> {
await act(async () => {
renderer?.update(createElement(Probe, { connState }))
await Promise.resolve()
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
loadSnapshot.mockReset()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('keeps the loaded review across a disconnect and its reconnect reload', async () => {
let releaseReload: (() => void) | null = null
loadSnapshot.mockResolvedValueOnce(readySnapshot('feature/one')).mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseReload = () => resolve(readySnapshot('feature/two'))
})
)
await act(async () => {
renderer = create(createElement(Probe, { connState: 'connected' }))
await Promise.resolve()
})
expect(screenState).toMatchObject({ kind: 'ready' })
await update('reconnecting')
expect(screenState).toMatchObject({ kind: 'ready', status: { branch: 'feature/one' } })
await update('connected')
expect(screenState).toMatchObject({ kind: 'ready', status: { branch: 'feature/one' } })
await act(async () => {
releaseReload?.()
await Promise.resolve()
})
expect(screenState).toMatchObject({ kind: 'ready', status: { branch: 'feature/two' } })
})
it('keeps the loaded review when the reconnect reload rejects', async () => {
loadSnapshot
.mockResolvedValueOnce(readySnapshot('feature/one'))
.mockRejectedValueOnce(new Error('snapshot fetch failed'))
await act(async () => {
renderer = create(createElement(Probe, { connState: 'connected' }))
await Promise.resolve()
})
expect(screenState).toMatchObject({ kind: 'ready' })
await update('reconnecting')
await update('connected')
expect(loadSnapshot).toHaveBeenCalledTimes(2)
// Why (F10): a failed refresh must not replace the review on screen with an error.
expect(screenState).toMatchObject({ kind: 'ready', status: { branch: 'feature/one' } })
})
it('waits for the desktop when the drop lands before the review loads', async () => {
loadSnapshot.mockReturnValueOnce(new Promise(() => {}))
await act(async () => {
renderer = create(createElement(Probe, { connState: 'connected' }))
await Promise.resolve()
})
expect(screenState).toMatchObject({ kind: 'loading' })
await update('disconnected')
expect(screenState).toMatchObject({ kind: 'error', message: 'Waiting for desktop...' })
})
})
@@ -16,15 +16,12 @@ import {
findMobileDiffReviewInitialIndex,
type MobileDiffReviewInitialTarget
} from './mobile-diff-review-positioning'
import {
loadMobileDiffReviewDiff,
loadMobileDiffReviewSnapshot
} from './mobile-diff-review-loaders'
import { loadMobileDiffReviewSnapshot } from './mobile-diff-review-loaders'
import { useMobileDiffReviewDiffLoading } from './use-mobile-diff-review-diff-loading'
import { canOpenMobileBranchCompareDiff } from '../source-control/mobile-branch-compare'
import type {
ComposerState,
ReviewDiffLine,
ReviewDiffState,
ReviewScreenState,
SendSheetState
} from './mobile-diff-review-screen-model'
@@ -60,7 +57,6 @@ export function useMobileDiffReviewController(input: ControllerInput) {
const seededInitialTargetRef = useRef(false)
const initialTargetKey = initialTarget ? `${initialTarget.area}\0${initialTarget.filePath}` : ''
const [screenState, setScreenState] = useState<ReviewScreenState>({ kind: 'loading' })
const [diffState, setDiffState] = useState<ReviewDiffState>({ kind: 'idle' })
const [filter, setFilter] = useState<MobileDiffReviewQueueFilter>(initialFilter)
const [currentIndex, setCurrentIndex] = useState(0)
const [activeHunkIndex, setActiveHunkIndex] = useState<number | null>(null)
@@ -82,11 +78,15 @@ export function useMobileDiffReviewController(input: ControllerInput) {
setScreenState({ kind: 'error', message: 'Missing worktree' })
return
}
// Why (F10): a loaded review outlives a blip — the waiting state is for a screen with nothing
// to show, and this branch (not the one below it) is the one a drop actually reaches.
const keepReady = (fallback: ReviewScreenState) => (prev: ReviewScreenState) =>
prev.kind === 'ready' ? prev : fallback
if (!client || connState !== 'connected') {
setScreenState({ kind: 'error', message: 'Waiting for desktop...' })
setScreenState(keepReady({ kind: 'error', message: 'Waiting for desktop...' }))
return
}
setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' }))
setScreenState(keepReady({ kind: 'loading' }))
try {
const nextState = await loadMobileDiffReviewSnapshot(client, worktreeId)
if (!isCurrent()) {
@@ -96,10 +96,14 @@ export function useMobileDiffReviewController(input: ControllerInput) {
setActionError(nextState.kind === 'ready' ? (nextState.branchError ?? null) : null)
} catch (err) {
if (isCurrent()) {
setScreenState({
kind: 'error',
message: err instanceof Error ? err.message : 'Unable to load review'
})
// Why (F10): a failed refresh after reconnect must not destroy the review
// already on screen; the error state is for a screen with nothing to show.
setScreenState(
keepReady({
kind: 'error',
message: err instanceof Error ? err.message : 'Unable to load review'
})
)
}
}
}, [client, connState, worktreeId])
@@ -160,42 +164,14 @@ export function useMobileDiffReviewController(input: ControllerInput) {
}
}, [currentIndex, filteredQueue.length])
useEffect(() => {
setActiveHunkIndex(null)
if (!currentItem || screenState.kind !== 'ready') {
setDiffState({ kind: 'idle' })
return
}
if (!client || connState !== 'connected') {
setDiffState({ kind: 'error', itemKey: currentItem.key, message: 'Waiting for desktop...' })
return
}
let stale = false
setDiffState({ kind: 'loading', itemKey: currentItem.key })
void loadMobileDiffReviewDiff({
client,
worktreeId,
item: currentItem,
branchCompare: screenState.branchCompare
})
.then((nextState) => {
if (!stale) {
setDiffState(nextState)
}
})
.catch((err: unknown) => {
if (!stale) {
setDiffState({
kind: 'error',
itemKey: currentItem.key,
message: err instanceof Error ? err.message : 'Unable to load diff'
})
}
})
return () => {
stale = true
}
}, [client, connState, currentItem, screenState, worktreeId])
const diffState = useMobileDiffReviewDiffLoading({
client,
connState,
worktreeId,
currentItem,
screenState,
setActiveHunkIndex
})
const commentsForCurrentItem = useMemo(() => {
if (!currentItem || screenState.kind !== 'ready') {
@@ -0,0 +1,119 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
import type { ReviewDiffState, ReviewScreenState } from './mobile-diff-review-screen-model'
import { useMobileDiffReviewDiffLoading } from './use-mobile-diff-review-diff-loading'
const loadDiff = vi.hoisted(() => vi.fn())
vi.mock('./mobile-diff-review-loaders', () => ({ loadMobileDiffReviewDiff: loadDiff }))
const client = { sendRequest: vi.fn() } as unknown as RpcClient
// Stable identity, like the controller's useState setter: it is an effect dependency.
const setActiveHunkIndex = () => {}
const currentItem = { key: 'item-1', filePath: 'src/app.ts' } as MobileDiffReviewQueueItem
const readyScreen = { kind: 'ready', branchCompare: null } as unknown as ReviewScreenState
function readyDiff(firstLine: string): ReviewDiffState {
return {
kind: 'ready',
itemKey: 'item-1',
lines: [{ kind: 'context', text: firstLine }],
hunks: [],
truncated: false
} as unknown as ReviewDiffState
}
describe('useMobileDiffReviewDiffLoading', () => {
let renderer: ReactTestRenderer | null = null
let diffState: ReviewDiffState = { kind: 'idle' }
function Probe({ connState }: { connState: ConnectionState }): null {
diffState = useMobileDiffReviewDiffLoading({
client,
connState,
worktreeId: 'wt-1',
currentItem,
screenState: readyScreen,
setActiveHunkIndex
})
return null
}
async function render(connState: ConnectionState): Promise<void> {
await act(async () => {
renderer = create(createElement(Probe, { connState }))
await Promise.resolve()
})
}
async function update(connState: ConnectionState): Promise<void> {
await act(async () => {
renderer?.update(createElement(Probe, { connState }))
await Promise.resolve()
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
loadDiff.mockReset()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('keeps the loaded diff when the reconnect refetch rejects', async () => {
loadDiff
.mockResolvedValueOnce(readyDiff('before the drop'))
.mockRejectedValueOnce(new Error('diff fetch failed'))
await render('connected')
expect(diffState).toMatchObject({ kind: 'ready' })
await update('reconnecting')
await update('connected')
// Why (F10): a failed refetch must not erase the diff (or hunk context) on screen.
expect(diffState).toMatchObject({ kind: 'ready', lines: [{ text: 'before the drop' }] })
expect(loadDiff).toHaveBeenCalledTimes(2)
})
it('keeps the loaded diff through a disconnect and its reconnect refetch', async () => {
let releaseRefetch: (() => void) | null = null
loadDiff.mockResolvedValueOnce(readyDiff('before the drop')).mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseRefetch = () => resolve(readyDiff('after the drop'))
})
)
await render('connected')
expect(diffState).toMatchObject({ kind: 'ready' })
await update('reconnecting')
expect(diffState).toMatchObject({ kind: 'ready', lines: [{ text: 'before the drop' }] })
await update('connected')
expect(diffState).toMatchObject({ kind: 'ready', lines: [{ text: 'before the drop' }] })
await act(async () => {
releaseRefetch?.()
await Promise.resolve()
})
expect(diffState).toMatchObject({ kind: 'ready', lines: [{ text: 'after the drop' }] })
expect(loadDiff).toHaveBeenCalledTimes(2)
})
it('waits for the desktop when the drop lands before any diff is loaded', async () => {
loadDiff.mockReturnValueOnce(new Promise(() => {}))
await render('connected')
expect(diffState).toMatchObject({ kind: 'loading', itemKey: 'item-1' })
await update('disconnected')
expect(diffState).toMatchObject({ kind: 'error', message: 'Waiting for desktop...' })
})
})
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from 'react'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { loadMobileDiffReviewDiff } from './mobile-diff-review-loaders'
import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
import type { ReviewDiffState, ReviewScreenState } from './mobile-diff-review-screen-model'
type DiffLoadingInput = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
currentItem: MobileDiffReviewQueueItem | null
screenState: ReviewScreenState
setActiveHunkIndex: (index: number | null) => void
}
// Owns the diff body for the reviewed item. Split out of the review controller so the loaded diff
// can survive a transport blip: a drop re-runs this effect, and (F10) a diff already on screen for
// the same item stays there instead of being replaced by "Waiting for desktop..." or a spinner.
export function useMobileDiffReviewDiffLoading(input: DiffLoadingInput): ReviewDiffState {
const { client, connState, worktreeId, currentItem, screenState, setActiveHunkIndex } = input
const [diffState, setDiffState] = useState<ReviewDiffState>({ kind: 'idle' })
const hunkResetKeyRef = useRef<string | null>(null)
// Why: depend on the two fields this effect reads, not the screenState object —
// an identity-only change must not restart the git.diff request.
const screenReady = screenState.kind === 'ready'
const branchCompare = screenState.kind === 'ready' ? screenState.branchCompare : null
useEffect(() => {
// Why (F10): a connection blip re-runs this effect; the reader's hunk position must
// survive it and reset only when the reviewed item actually changes.
const hunkKey = currentItem?.key ?? null
if (hunkResetKeyRef.current !== hunkKey) {
hunkResetKeyRef.current = hunkKey
setActiveHunkIndex(null)
}
if (!currentItem || !screenReady) {
setDiffState({ kind: 'idle' })
return
}
const itemKey = currentItem.key
const keepLoadedDiff = (fallback: ReviewDiffState) => (prev: ReviewDiffState) =>
prev.kind === 'ready' && prev.itemKey === itemKey ? prev : fallback
if (!client || connState !== 'connected') {
setDiffState(keepLoadedDiff({ kind: 'error', itemKey, message: 'Waiting for desktop...' }))
return
}
let stale = false
setDiffState(keepLoadedDiff({ kind: 'loading', itemKey }))
void loadMobileDiffReviewDiff({
client,
worktreeId,
item: currentItem,
branchCompare
})
.then((nextState) => {
if (!stale) {
setDiffState(nextState)
}
})
.catch((err: unknown) => {
if (!stale) {
// Why (F10): a rejected reconnect refetch must not erase the diff on screen.
setDiffState(
keepLoadedDiff({
kind: 'error',
itemKey,
message: err instanceof Error ? err.message : 'Unable to load diff'
})
)
}
})
return () => {
stale = true
}
}, [client, connState, currentItem, screenReady, branchCompare, setActiveHunkIndex, worktreeId])
return diffState
}
@@ -98,7 +98,13 @@ describe('useMobileFileTapHandlers', () => {
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'terminal-1', cwd: '/repo/sub' },
{
worktree: 'id:wt-1',
pathText: 'index.ts',
terminal: 'terminal-1',
cwd: '/repo/sub',
crossWorkspace: true
},
{ timeoutMs: 10_000 }
)
})
@@ -126,7 +132,7 @@ describe('useMobileFileTapHandlers', () => {
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'mobile/src/x.ts' },
{ worktree: 'id:wt-1', pathText: 'mobile/src/x.ts', crossWorkspace: true },
{ timeoutMs: 10_000 }
)
expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
@@ -7,9 +7,11 @@ import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dism
describe('useMobileNativeChatAskDismiss', () => {
let renderer: ReactTestRenderer | null = null
let state: ReturnType<typeof useMobileNativeChatAskDismiss> | null = null
let renders = 0
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
renders = 0
})
afterEach(() => {
@@ -18,11 +20,51 @@ describe('useMobileNativeChatAskDismiss', () => {
state = null
})
function Harness({ prompt }: { prompt: AskPrompt }): null {
state = useMobileNativeChatAskDismiss(prompt)
function Harness({
prompt,
detectedPrompt = prompt,
scopeKey = 'tab-1',
sessionKey = 'session-1',
observing = true
}: {
prompt: AskPrompt | null
detectedPrompt?: AskPrompt | null
scopeKey?: string | null
sessionKey?: string | null
observing?: boolean
}): null {
renders += 1
state = useMobileNativeChatAskDismiss({
ask: prompt,
detectedAsk: detectedPrompt,
scopeKey,
sessionKey,
observing
})
return null
}
async function mount(props: Parameters<typeof Harness>[0]): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, props))
})
} finally {
consoleSpy.mockRestore()
}
}
async function update(props: Parameters<typeof Harness>[0]): Promise<void> {
await act(async () => renderer?.update(createElement(Harness, props)))
}
const first: AskPrompt = {
questions: [
{ question: 'same first', multiSelect: false, options: [] },
@@ -37,24 +79,129 @@ describe('useMobileNativeChatAskDismiss', () => {
}
it('shows a structurally different replacement without an intervening null', async () => {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { prompt: first }))
})
} finally {
consoleSpy.mockRestore()
}
await mount({ prompt: first })
act(() => state?.dismissAsk())
expect(state?.showAsk).toBe(false)
await act(async () => renderer?.update(createElement(Harness, { prompt: replacement })))
await update({ prompt: replacement })
expect(state?.showAsk).toBe(true)
await update({ prompt: first })
expect(state?.showAsk).toBe(true)
})
it('keeps a dismissal while status gating hides a still-detected prompt', async () => {
await mount({ prompt: first })
const acceptedDismiss = state!.dismissAsk
await update({ prompt: null, detectedPrompt: first })
act(() => acceptedDismiss())
await update({ prompt: first })
expect(state?.showAsk).toBe(false)
})
it('ignores an answer that settles after its prompt cleared', async () => {
await mount({ prompt: first })
const lateDismiss = state!.dismissAsk
await update({ prompt: null })
act(() => lateDismiss())
await update({ prompt: first })
expect(state?.showAsk).toBe(true)
})
it('keeps a dismissal taken on-chat across a chat→terminal→chat toggle', async () => {
// The common case: answer the card, then toggle to the terminal. The prompt
// derives to null while hidden, and that null must not retire the dismissal.
await mount({ prompt: first })
act(() => state?.dismissAsk())
expect(state?.showAsk).toBe(false)
await update({ prompt: null, observing: false })
await update({ prompt: first, observing: true })
expect(state?.showAsk).toBe(false)
})
it('keeps a dismissal across a chat→terminal→chat toggle', async () => {
// While the chat surface is hidden the prompt derives to null; that null
// proves nothing about the agent and must not reset the dismissal.
await mount({ prompt: first })
const acceptedDismiss = state!.dismissAsk
await update({ prompt: null, observing: false })
act(() => acceptedDismiss())
await update({ prompt: first, observing: true })
expect(state?.showAsk).toBe(false)
})
it('forgets the dismissal once the prompt clears while observable', async () => {
await mount({ prompt: first })
act(() => state?.dismissAsk())
// Agent moved on: the prompt cleared with chat visible.
await update({ prompt: null, observing: true })
await update({ prompt: first, observing: true })
expect(state?.showAsk).toBe(true)
})
it('scopes the dismissal to the tab that showed the card', async () => {
await mount({ prompt: first, scopeKey: 'tab-1' })
act(() => state?.dismissAsk())
// The same question on another tab is a different pending prompt.
await update({ prompt: first, scopeKey: 'tab-2' })
expect(state?.showAsk).toBe(true)
act(() => state?.dismissAsk())
expect(state?.showAsk).toBe(false)
// Dismissing tab 2 must not overwrite tab 1's dismissal.
await update({ prompt: first, scopeKey: 'tab-1' })
expect(state?.showAsk).toBe(false)
await update({ prompt: first, scopeKey: 'tab-2' })
expect(state?.showAsk).toBe(false)
})
it('shows an identical prompt after the tab starts a new provider session', async () => {
await mount({ prompt: first, sessionKey: 'session-1' })
act(() => state?.dismissAsk())
await update({ prompt: first, sessionKey: 'session-2' })
expect(state?.showAsk).toBe(true)
})
it('reports nothing to show without a prompt', async () => {
await mount({ prompt: null })
expect(state?.showAsk).toBe(false)
})
it('does not re-render a scope with nothing dismissed when the prompt changes', async () => {
await mount({ prompt: first })
const afterMount = renders
await update({ prompt: replacement })
// One render for the prop change and no more: the reset effect must hand back
// the same Map when this scope has no dismissal, or every observed prompt
// change commits a fresh one. The hook sits on the session route, so that
// wasted commit re-renders the whole session surface.
expect(renders).toBe(afterMount + 1)
})
it('records a settled answer against its originating tab', async () => {
await mount({ prompt: first, scopeKey: 'tab-1' })
const tabOneDismiss = state!.dismissAsk
await update({ prompt: replacement, scopeKey: 'tab-2' })
act(() => tabOneDismiss())
await update({ prompt: first, scopeKey: 'tab-1' })
expect(state?.showAsk).toBe(false)
})
})
@@ -1,26 +1,79 @@
import { useEffect, useState } from 'react'
import type { AskPrompt } from './mobile-native-chat-ask'
import { useEffect, useMemo, useRef, useState } from 'react'
import { nativeChatAskDismissKey, type AskPrompt } from './mobile-native-chat-ask'
type AskDismissal = { sessionKey: string | null; askKey: string }
type DetectedAsk = { sessionKey: string | null; askKey: string | null }
/** Track the answered-ask key so the lingering live status doesn't re-show the
* same card. The agent emits a post-tool event with the same prompt right after
* an answer, so the card is hidden until a genuinely different question arrives. */
export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): {
* an answer, so the card is hidden until a genuinely different question arrives.
*
* Owned by the controller, not the chat subtree: the overlay unmounts on a
* chat↔terminal view toggle, and a dismissal must survive that round-trip. */
export function useMobileNativeChatAskDismiss(args: {
ask: AskPrompt | null
/** Ungated prompt payload. A working/done status hides the card but does not
* prove the sticky prompt itself cleared. Required, and never defaulted to
* `ask`: reading the gated prompt as the detected one is the resurfacing bug
* this hook exists to close. */
detectedAsk: AskPrompt | null
/** Tab scope retains dismissals across tab switches. */
scopeKey: string | null
/** Provider-session identity distinguishes restarts without growing the tab map. */
sessionKey: string | null
/** True while the chat surface can actually observe the prompt. A null ask it
* cannot see — off-chat, or before a re-subscribed transcript lands — proves
* nothing and must not reset the dismissal; that reset resurfaced the card. */
observing: boolean
}): {
askKey: string | null
showAsk: boolean
dismissAsk: () => void
} {
const askKey = ask ? JSON.stringify(ask.questions) : null
const [dismissedAskKey, setDismissedAskKey] = useState<string | null>(null)
// Once the prompt clears (agent moved on), forget the dismissal so a later
// question — even an identical one — shows again instead of staying hidden.
const askPresent = ask != null
const { ask, detectedAsk, scopeKey, sessionKey, observing } = args
const askKey = useMemo(() => nativeChatAskDismissKey(ask), [ask])
const detectedAskKey = useMemo(() => nativeChatAskDismissKey(detectedAsk), [detectedAsk])
const detectedByScopeRef = useRef(new Map<string | null, DetectedAsk>())
const [dismissedByScope, setDismissedByScope] = useState<Map<string | null, AskDismissal>>(
() => new Map()
)
useEffect(() => {
if (!askPresent) {
setDismissedAskKey(null)
if (observing) {
detectedByScopeRef.current.set(scopeKey, { sessionKey, askKey: detectedAskKey })
}
}, [askPresent])
const showAsk = askPresent && askKey !== dismissedAskKey
const dismissAsk = (): void => setDismissedAskKey(askKey)
}, [observing, detectedAskKey, scopeKey, sessionKey])
// A cleared or genuinely different detected prompt retires the old dismissal.
useEffect(() => {
if (observing) {
setDismissedByScope((previous) => {
const dismissed = previous.get(scopeKey)
if (
dismissed === undefined ||
(dismissed.sessionKey === sessionKey && dismissed.askKey === detectedAskKey)
) {
return previous
}
const next = new Map(previous)
next.delete(scopeKey)
return next
})
}
}, [observing, detectedAskKey, scopeKey, sessionKey])
const dismissed = dismissedByScope.get(scopeKey)
const showAsk =
askKey !== null && !(dismissed?.sessionKey === sessionKey && dismissed.askKey === askKey)
const dismissAsk = (): void => {
const detected = detectedByScopeRef.current.get(scopeKey)
if (askKey !== null && detected?.sessionKey === sessionKey && detected.askKey === askKey) {
setDismissedByScope((previous) => {
const current = previous.get(scopeKey)
if (current?.sessionKey === sessionKey && current.askKey === askKey) {
return previous
}
return new Map(previous).set(scopeKey, { sessionKey, askKey })
})
}
}
return { askKey, showAsk, dismissAsk }
}
@@ -15,6 +15,12 @@ const holdUnconfirmedSend = vi.fn()
const viewMode = { isTabChatView: (_tabId: string) => true }
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
const draftsArgs: Record<string, unknown>[] = []
const promptsState = {
permission: null as unknown,
question: null as unknown,
detectedAsk: null as unknown,
ask: null as unknown
}
// The controller composes many session hooks; each is mocked to a minimal shape
// so this test isolates the send seam (outcome -> drafts accounting).
@@ -45,7 +51,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
}
}))
vi.mock('./use-mobile-native-chat-prompts', () => ({
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
useMobileNativeChatPrompts: () => promptsState
}))
const answerSendArgs: { streamIdentity?: string }[] = []
vi.mock('./use-mobile-native-chat-answer-send', () => ({
@@ -80,6 +86,7 @@ import {
useMobileNativeChatController,
type MobileNativeChatController
} from './use-mobile-native-chat-controller'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
@@ -426,6 +433,292 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
})
})
describe('useMobileNativeChatController ask dismissal across a transcript reload', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileNativeChatController | null = null
const clientStub = { sendRequest: vi.fn() }
const PROMPT = { questions: [{ question: 'Which path?', multiSelect: false, options: [] }] }
const chatTab = { type: 'terminal', id: 'tab-1', launchAgent: 'claude' }
/** Mutable so a test can move the user to another tab — or restart the agent
* into a new provider session on the same tab — mid-render. */
const activeTab = { id: 'tab-1', sessionId: 'session-1' }
function Harness(): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState: 'connected',
hostId: 'h',
worktreeId: 'w',
activeSessionTab: {
...chatTab,
id: activeTab.id,
agentStatus: { agentType: 'claude', providerSession: { id: activeTab.sessionId } }
} as never,
activeSessionTabId: activeTab.id,
activeHandleRef: { current: 'term-1' },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
onSendError: vi.fn(),
onSendResolved: vi.fn()
})
return null
}
/** Re-render under the current viewMode/prompts/session stand-ins. */
function step(): void {
act(() => {
renderer?.update(createElement(Harness))
})
}
/** Drive the transcript stand-in the way the real hook couples its fields, so
* these tests can only express states the session hook can actually reach:
* `transcriptLoading` is exactly `status === 'loading'`, and `messages` is
* withheld until a read lands. `rows` is what the last landed read left
* behind — 0 means no read has ever landed for this identity. */
function setTranscript(status: MobileNativeChatStatus, rows = 1): void {
sessionState.status = status
sessionState.transcriptLoading = status === 'loading'
sessionState.messages =
status === 'ready' || status === 'error' ? Array.from({ length: rows }, () => ({})) : []
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
viewMode.isTabChatView = () => true
setTranscript('ready')
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
return
}
original(...a)
})
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
spy.mockRestore()
}
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
controller = null
promptsState.ask = null
promptsState.detectedAsk = null
setTranscript('ready', 0)
viewMode.isTabChatView = () => true
activeTab.id = 'tab-1'
activeTab.sessionId = 'session-1'
})
it('scopes the dismissal to the tab it was taken on', () => {
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
// Another tab's agent is parked on a byte-identical question; it is a
// different pending prompt and tab 1's answer must not hide it.
activeTab.id = 'tab-2'
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
it('shows a restarted session’s identical first question on the same tab', () => {
// A restart, `/clear`, or resume swaps the provider session inside one tab,
// and the next session's first question is often byte-identical — same repo,
// same prompt, same template. Keyed by tab alone the old answer hides it, so
// the live card never renders and the turn sits blocked with nothing to act on.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
// The restart re-subscribes the transcript, so the read is in flight for a beat.
activeTab.sessionId = 'session-2'
setTranscript('loading')
promptsState.ask = null
promptsState.detectedAsk = null
step()
setTranscript('ready')
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
it('retires the dismissal only on the ungated prompt, not the gated one', () => {
// The paused gate hides the card whenever the agent is not waiting, but a
// hidden card is no evidence the sticky status prompt cleared. Feeding the
// gated `ask` in as the detected prompt would read that gap as "prompt gone",
// retire the dismissal, and bring the answered card back when it reopens.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
promptsState.ask = null
step()
promptsState.ask = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
it('keeps the dismissal while the re-subscribed transcript is still empty', () => {
// Toggling views re-subscribes the transcript, so a transcript-derived ask
// reads as null for a beat with chat already visible. Believing that null
// retires the dismissal and the answered card comes back (#12497).
expect(controller?.nativeChatAsk).not.toBeNull()
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
// Chat -> terminal.
viewMode.isTabChatView = () => false
promptsState.ask = null
promptsState.detectedAsk = null
step()
// Terminal -> chat: observable again, but the transcript read is in flight.
viewMode.isTabChatView = () => true
setTranscript('loading')
step()
// The read lands and re-derives the same still-pending ask.
setTranscript('ready')
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
it('accepts an answer taken while the first transcript read is still in flight', () => {
// A status-derived ask renders before any transcript lands, so the load window
// must stay observable whenever a prompt is actually on screen — otherwise the
// dismissal is silently dropped and the answered card never goes away.
act(() => renderer?.unmount())
setTranscript('loading')
act(() => {
renderer = create(createElement(Harness))
})
expect(controller?.nativeChatAsk).not.toBeNull()
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
})
it('still retires the dismissal once a settled transcript reports no prompt', () => {
// The load-window guard must not swallow the genuine reset: a prompt that
// clears with the read settled means the agent moved on.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
promptsState.ask = null
promptsState.detectedAsk = null
step()
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
it('keeps the dismissal while a dropped client empties the transcript', () => {
// `transcriptLoading` is only true for an in-flight read. A dropped client
// parks the session at 'idle', where the hook withholds `messages` with that
// flag already false — so the derived prompt reads null for a reason that
// says nothing about the agent, and the reset effect retired a live dismissal.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
setTranscript('idle')
promptsState.ask = null
promptsState.detectedAsk = null
step()
// Reconnect: the read lands and the same question is still pending.
setTranscript('ready')
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
it('keeps the dismissal when the first read of a transcript errors', () => {
// The host forwards an initial-drain failure as an error frame carrying an
// EMPTY list, and that frame is not terminal — a real snapshot follows once
// the read recovers. Judging the prompt from that never-populated list
// retires the dismissal, and the recovered read brings the answered card
// back over the composer.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
setTranscript('error', 0)
promptsState.ask = null
promptsState.detectedAsk = null
step()
// The read recovers with the same question still pending.
setTranscript('ready')
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
it('retires the dismissal when a failed read still reports the last transcript', () => {
// A read error that lands on top of rows from an earlier read leaves those
// rows in `messages`, so a prompt that clears under it is real evidence —
// unlike the never-read empty list of 'idle'/'waiting-session'. Treating
// every error as unobservable would freeze the dismissal and hide the next
// identical question for good.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
setTranscript('error')
promptsState.ask = null
promptsState.detectedAsk = null
step()
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
it('keeps the dismissal while the tab has no provider session yet', () => {
// 'waiting-session' withholds `messages` the same way, also with
// transcriptLoading false. The tab still shows chat (resolveMobileNativeChat
// resolves from `launchAgent` alone), so a live dismissal is on screen for it.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
setTranscript('waiting-session')
promptsState.ask = null
promptsState.detectedAsk = null
step()
setTranscript('ready')
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
})
describe('useMobileNativeChatController streaming scope', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileNativeChatController | null = null
@@ -489,8 +782,6 @@ describe('useMobileNativeChatController streaming scope', () => {
})
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
// Both feed the streaming gate, which lives above the chat view's mount and
// must not be reset by a view toggle.
expect(controller?.showNativeChat).toBe(true)
const scopeKey = controller?.nativeChatStreamScopeKey
expect(scopeKey).toContain('session-1')
@@ -506,9 +797,6 @@ describe('useMobileNativeChatController streaming scope', () => {
})
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
// `streamIdentity` fences a delayed answer/stop to the route it was armed
// on, so it must still drop its session when chat closes — the scope key is
// the one that has to survive the toggle. Same string while chat is open.
const before = answerSendArgs.at(-1)?.streamIdentity
expect(before).toBe(controller?.nativeChatStreamScopeKey)
@@ -1,4 +1,11 @@
import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import {
useCallback,
useLayoutEffect,
useRef,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -13,6 +20,7 @@ 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,
@@ -20,7 +28,10 @@ import {
} 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'
@@ -48,7 +59,13 @@ export type MobileNativeChatController = {
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[]
@@ -72,6 +89,9 @@ export type MobileNativeChatController = {
/** 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
@@ -115,9 +135,12 @@ export function useMobileNativeChatController(args: {
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
showNativeChatRef.current = showNativeChat
const activeChatAgentRef = useRef<string | null>(activeChatResolution?.agent ?? null)
activeChatAgentRef.current = activeChatResolution?.agent ?? null
const activeChatAgent = activeChatResolution?.agent ?? null
const activeChatAgentRef = useRef<string | null>(activeChatAgent)
useLayoutEffect(() => {
showNativeChatRef.current = showNativeChat
activeChatAgentRef.current = activeChatAgent
}, [activeChatAgent, showNativeChat])
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
@@ -173,12 +196,30 @@ export function useMobileNativeChatController(args: {
const {
permission: nativeChatPermission,
question: nativeChatQuestion,
ask: nativeChatAsk
detectedAsk: nativeChatDetectedAsk,
ask: nativeChatAskPrompt
} = useMobileNativeChatPrompts({
enabled: activeChatResolution != null,
status: nativeChatStatus,
messages: nativeChatSession.messages
})
// A never-read transcript cannot prove that a dismissed prompt cleared.
const nativeChatTranscriptSettled =
nativeChatSession.status === 'ready' ||
(nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0)
const nativeChatAskObservable =
showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
const {
askKey: nativeChatAskKey,
showAsk: showNativeChatAsk,
dismissAsk: dismissNativeChatAsk
} = useMobileNativeChatAskDismiss({
ask: nativeChatAskPrompt,
detectedAsk: nativeChatDetectedAsk,
scopeKey: activeSessionTabId,
sessionKey: activeChatSessionId,
observing: nativeChatAskObservable
})
// Every chat write gates on both: the lease proves the input floor is ours, and
// `connState` collapses a render before the lease does on disconnect.
@@ -228,15 +269,23 @@ export function useMobileNativeChatController(args: {
worktreeId
})
// Why: the send seam reports outgoing catalog commands to session-option
// tracking, but the options hook needs the seam's dispatcher — a ref breaks
// the cycle without re-creating the send callbacks per snapshot.
const recordSessionOptionCommandRef = useRef<(command: string) => void>(() => {})
const {
send: handleNativeChatSend,
sendWithOutcome: handleNativeChatSendWithOutcome,
answerQuestion: handleNativeChatQuestionAnswer
answerQuestion: handleNativeChatQuestionAnswer,
dispatchCommand: handleNativeChatDispatchCommand
} = useMobileNativeChatMessageSend({
client,
enabled: inputSendable,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
commandSendRef: recordSessionOptionCommandRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
@@ -245,6 +294,25 @@ export function useMobileNativeChatController(args: {
holdUnconfirmedSend,
onSendError
})
// A Codex-style model change happens in the agent's own TUI picker — bring
// the terminal view forward so the dispatched `/model` selector is visible.
const handleAgentPicker = useCallback(() => {
if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
toggleTabChatView(activeSessionTabId)
}
}, [activeSessionTabId, isTabChatView, toggleTabChatView])
const sessionOptions = useMobileNativeChatSessionOptions({
agent: activeChatResolution?.agent ?? null,
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
reportedModel: activeSessionTab?.agentStatus?.model ?? null,
dispatchCommand: handleNativeChatDispatchCommand,
onAgentPicker: handleAgentPicker
})
useLayoutEffect(() => {
recordSessionOptionCommandRef.current = sessionOptions.recordCommand
}, [sessionOptions.recordCommand])
// Card actions retire the route's held failure banner too, not just sends.
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
@@ -266,7 +334,9 @@ export function useMobileNativeChatController(args: {
nativeChatStreamScopeKey: streamScopeKey,
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk,
nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null,
nativeChatAskKey,
dismissNativeChatAsk,
handleNativeChatAnswerAsk: answerAsk,
handleNativeChatCancelAsk: cancelAsk,
handleNativeChatRespondPermission: respond,
@@ -276,6 +346,10 @@ export function useMobileNativeChatController(args: {
handleNativeChatQuestionAnswer,
handleNativeChatSend,
handleNativeChatSendWithOutcome,
readSeededLaunchDraft
readSeededLaunchDraft,
nativeChatSessionOptions:
sessionOptions.snapshot.length > 0
? { controller: sessionOptions, isWorking: nativeChatAgentWorking }
: null
}
}
@@ -33,23 +33,32 @@ const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
describe('useMobileNativeChatMessageSend', () => {
let renderer: ReactTestRenderer | null = null
let api: Send | null = null
const acceptSend = vi.fn()
const holdUnconfirmedSend = vi.fn()
const onCommandSend = vi.fn()
const commandSendRef = { current: onCommandSend }
const agentRef = { current: null as string | null }
let onSendError = vi.fn()
const mount = (
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null,
agent: string | null = 'claude'
): void => {
agentRef.current = agent
function Probe(): null {
api = useMobileNativeChatMessageSend({
client: { sendRequest: vi.fn() } as never,
enabled: true,
handleRef: { current: 'term' },
deviceTokenRef: { current: 'device' },
agentRef,
commandSendRef,
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
readSeededLaunchDraftSeed,
clearDraftForSend: () => {},
restoreRejectedDraft: () => {},
acceptSend: () => {},
holdUnconfirmedSend: () => {},
acceptSend,
holdUnconfirmedSend,
onSendError
})
return null
@@ -76,6 +85,10 @@ describe('useMobileNativeChatMessageSend', () => {
sendWithOutcome.mockResolvedValue('accepted')
clearInputWrite.mockReset()
clearInputWrite.mockResolvedValue(true)
acceptSend.mockReset()
holdUnconfirmedSend.mockReset()
onCommandSend.mockReset()
commandSendRef.current = onCommandSend
onSendError = vi.fn()
resetMobileNativeChatTerminalWritesForTests()
})
@@ -182,6 +195,117 @@ describe('useMobileNativeChatMessageSend', () => {
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
it('creates an optimistic echo for an ordinary chat send', async () => {
mount(() => null)
await act(async () => {
await api!.send('hello')
})
expect(acceptSend).toHaveBeenCalledTimes(1)
expect(onCommandSend).not.toHaveBeenCalled()
})
// The STA-3332 "Queued forever" regression: command sends dispatch into the
// agent's TUI and never echo as user turns, so they must not create a pending
// bubble that no transcript match can ever retire.
it('never creates an optimistic echo for a catalog command send', async () => {
mount(() => null)
await act(async () => {
await api!.send('/clear')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/clear')
})
it('never creates an optimistic echo for an unknown slash token', async () => {
// `/model` is not in Claude's autocomplete catalog, but the session-option
// recorder still recognizes it without claiming a generic command ran.
mount(() => null)
await act(async () => {
await api!.send('/model sonnet')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
})
it('classifies per agent: /model is a catalog command for Codex', async () => {
mount(() => null, 'codex')
await act(async () => {
await api!.send('/model')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model')
})
it('holds only chat sends for transcript confirmation on a lost ack', async () => {
sendWithOutcome.mockResolvedValue('unknown')
mount(() => null)
await act(async () => {
await api!.send('/clear')
})
expect(holdUnconfirmedSend).not.toHaveBeenCalled()
await act(async () => {
await api!.send('hello')
})
expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1)
})
it('dispatchCommand surfaces the outcome without echo or composer sync', async () => {
mount(() => ({ text: DRAFT, createdAt: 1 }))
let outcome: string | undefined
await act(async () => {
outcome = await api!.dispatchCommand('/model sonnet')
})
expect(outcome).toBe('accepted')
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).not.toHaveBeenCalled()
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
it('binds classification to the agent that started the send', async () => {
let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void
sendWithOutcome.mockReturnValue(
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolveSend = resolve
})
)
mount(() => null, 'claude')
let sending!: Promise<boolean>
act(() => {
sending = api!.send('$skill')
})
agentRef.current = 'codex'
await act(async () => {
resolveSend('accepted')
await sending
})
expect(acceptSend).toHaveBeenCalledTimes(1)
})
it('records a command against the tab that started the send', async () => {
let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void
sendWithOutcome.mockReturnValue(
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolveSend = resolve
})
)
const originalRecorder = vi.fn()
const nextRecorder = vi.fn()
commandSendRef.current = originalRecorder
mount(() => null)
commandSendRef.current = originalRecorder
let sending!: Promise<boolean>
act(() => {
sending = api!.send('/clear')
})
commandSendRef.current = nextRecorder
await act(async () => {
resolveSend('accepted')
await sending
})
expect(originalRecorder).toHaveBeenCalledWith('/clear')
expect(nextRecorder).not.toHaveBeenCalled()
})
it('rejects a question answer while another composed write holds the terminal', async () => {
mount(() => null)
// An image paste sequence is mid-flight into the same PTY.
@@ -7,6 +7,7 @@ import {
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
@@ -29,6 +30,9 @@ export type MobileNativeChatMessageSend = {
) => Promise<MobileNativeChatSendOutcome>
/** Answer to an agent question — never touches the composer draft. */
answerQuestion: (text: string) => Promise<boolean>
/** Session-option command dispatch (e.g. `/model sonnet`) — never touches the
* composer draft; callers need the outcome to track dispatched state. */
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
}
/** The native-chat send seam: one write path shared by composer sends, image
@@ -38,6 +42,11 @@ export function useMobileNativeChatMessageSend(args: {
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
/** Active tab's agent — classification is per-agent (command catalogs differ). */
agentRef: MutableRefObject<string | null>
/** Captured when a control send starts so a later tab switch cannot record its
* session-option effects against the newly active tab. */
commandSendRef: MutableRefObject<(command: string) => void>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
* at send time so the pre-clear can be sized to every line it occupies. */
@@ -57,6 +66,8 @@ export function useMobileNativeChatMessageSend(args: {
enabled,
handleRef,
deviceTokenRef,
agentRef,
commandSendRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
@@ -71,10 +82,13 @@ export function useMobileNativeChatMessageSend(args: {
text: string,
images: string[] | undefined,
syncComposer: boolean,
recordControlSend: boolean,
sharedDeadline?: number
): Promise<MobileNativeChatSendOutcome> => {
const handle = handleRef.current
const origin = captureSendOrigin(text)
const agent = agentRef.current
const recordCommand = commandSendRef.current
// Why: the lease collapses one render after `connState`, so a question-card
// answer (which reaches this send directly) would otherwise burn the whole
// 15s heal+send budget waiting on a socket that is already gone.
@@ -165,12 +179,19 @@ export function useMobileNativeChatMessageSend(args: {
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
// Why (desktop parity): a slash/skill send dispatches into the agent's own
// TUI, not the conversation — the transcript never echoes it as a user
// turn, so an optimistic bubble would sit at "Queued" forever and the
// unconfirmed hold could never observe a landing.
const classification = classifyMobileNativeChatSend(agent, text)
if (outcome === 'unknown') {
// Why: an ack-lost send usually WAS delivered (issue seen on cellular
// relay) — verify via the transcript echo instead of a false "not sent".
holdUnconfirmedSend(origin, text, () =>
onSendError('Delivery unconfirmed — check chat before retrying')
)
if (classification === 'chat') {
// Why: an ack-lost send usually WAS delivered (issue seen on cellular
// relay) — verify via the transcript echo instead of a false "not sent".
holdUnconfirmedSend(origin, text, () =>
onSendError('Delivery unconfirmed — check chat before retrying')
)
}
return 'unknown'
}
if (outcome === 'rejected') {
@@ -180,16 +201,24 @@ export function useMobileNativeChatMessageSend(args: {
onSendError('Message not sent')
return 'rejected'
}
// `images` are local preview URIs for the optimistic echo only — the actual
// image bytes already rode along as a bracketed paste before this text send.
acceptSend(origin, text, images)
if (classification === 'chat') {
// `images` are local preview URIs for the optimistic echo only — the actual
// image bytes already rode along as a bracketed paste before this text send.
acceptSend(origin, text, images)
} else if (recordControlSend) {
// The session-option catalog can recognize controls omitted from the
// autocomplete catalog (for example Claude `/model` and `/fast`).
recordCommand(text.trim())
}
return 'accepted'
},
[
acceptSend,
agentRef,
captureSendOrigin,
clearDraftForSend,
client,
commandSendRef,
deviceTokenRef,
enabled,
handleRef,
@@ -202,7 +231,7 @@ export function useMobileNativeChatMessageSend(args: {
const sendWithOutcome = useCallback(
(text: string, images?: string[], deadline?: number) =>
sendMessage(text, images, true, deadline),
sendMessage(text, images, true, true, deadline),
[sendMessage]
)
@@ -226,7 +255,7 @@ export function useMobileNativeChatMessageSend(args: {
return false
}
try {
return (await sendMessage(text, undefined, false)) !== 'rejected'
return (await sendMessage(text, undefined, false, true)) !== 'rejected'
} finally {
if (terminal) {
releaseMobileNativeChatTerminalWrite(terminal)
@@ -236,5 +265,25 @@ export function useMobileNativeChatMessageSend(args: {
[handleRef, onSendError, sendMessage]
)
return { send, sendWithOutcome, answerQuestion }
// A session-option apply writes to the same input line as a send, and the host
// spaces a send's body and its Enter ~500ms apart — so without this lock an
// apply lands between them and is submitted as part of the user's prompt.
const dispatchCommand = useCallback(
async (text: string): Promise<MobileNativeChatSendOutcome> => {
const terminal = handleRef.current
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
return 'rejected'
}
try {
return await sendMessage(text, undefined, false, false)
} finally {
if (terminal) {
releaseMobileNativeChatTerminalWrite(terminal)
}
}
},
[handleRef, sendMessage]
)
return { send, sendWithOutcome, answerQuestion, dispatchCommand }
}
@@ -2,26 +2,38 @@ import { createElement } from 'react'
import TestRenderer from 'react-test-renderer'
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
const APPROVAL = JSON.stringify({
approval: { tool: 'Bash', summary: 'pnpm build > build.log 2>&1' }
})
function permissionFor(status: Partial<AgentStatusEntry> | null): unknown {
let captured: unknown
const ASK = JSON.stringify({
questions: [{ question: 'Which path?', options: ['fast', 'safe'] }]
})
function promptsFor(
status: Partial<AgentStatusEntry> | null,
messages: NativeChatMessage[] = []
): ReturnType<typeof useMobileNativeChatPrompts> {
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
function Probe(): null {
captured = useMobileNativeChatPrompts({
enabled: true,
status: status as AgentStatusEntry | null,
messages: []
}).permission
messages
})
return null
}
TestRenderer.act(() => {
TestRenderer.create(createElement(Probe))
})
return captured
return captured!
}
function permissionFor(status: Partial<AgentStatusEntry> | null): unknown {
return promptsFor(status).permission
}
describe('useMobileNativeChatPrompts approval-envelope state gate', () => {
@@ -60,3 +72,74 @@ describe('useMobileNativeChatPrompts approval-envelope state gate', () => {
expect(permission?.options.map((o) => o.label)).toEqual(['Yes', 'No'])
})
})
describe('useMobileNativeChatPrompts ask state gate', () => {
const askMessages: NativeChatMessage[] = [
{
id: 'm1',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'AskUserQuestion',
input: { questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] }
}
],
timestamp: 0,
source: 'transcript'
}
]
it('renders the ask card only while the agent is waiting or blocked', () => {
expect(promptsFor({ state: 'waiting', interactivePrompt: ASK }).ask).toMatchObject({
questions: [{ question: 'Which path?' }]
})
expect(promptsFor({ state: 'blocked', interactivePrompt: ASK }).ask).not.toBeNull()
})
it('renders no ask card from a sticky prompt while the agent is working or done', () => {
// The prompt payload outlives its answer — same paused gate as permission.
const working = promptsFor({ state: 'working', interactivePrompt: ASK })
expect(working.ask).toBeNull()
expect(working.detectedAsk).not.toBeNull()
const done = promptsFor({ state: 'done', interactivePrompt: ASK })
expect(done.ask).toBeNull()
expect(done.detectedAsk).not.toBeNull()
})
it('keeps the transcript-derived pending ask outside the paused gate', () => {
// A hook row idle past AGENT_STATUS_STALE_AFTER_MS projects to `done` with no
// interactivePrompt, so gating this too would make a still-pending question
// unanswerable from mobile. `extractPendingAsk` clears on the tool result.
expect(promptsFor({ state: 'waiting' }, askMessages).ask).not.toBeNull()
expect(promptsFor({ state: 'done' }, askMessages).ask).not.toBeNull()
expect(promptsFor({ state: 'working' }, askMessages).ask).not.toBeNull()
expect(promptsFor(null, askMessages).ask).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
// both sources still describe the answered question. The paused gate only
// holds because a status prompt suppresses the transcript fallback outright.
const working = promptsFor({ state: 'working', interactivePrompt: ASK }, askMessages)
expect(working.ask).toBeNull()
expect(working.detectedAsk).not.toBeNull()
})
it('still refuses an unpaused sticky status prompt that the transcript does not back', () => {
const answered: NativeChatMessage[] = [
...askMessages,
{
id: 'm2',
role: 'tool',
blocks: [{ type: 'tool-result', output: 'fast' }],
timestamp: 1,
source: 'transcript'
}
]
expect(promptsFor({ state: 'done', interactivePrompt: ASK }, answered).ask).toBeNull()
expect(promptsFor({ state: 'done' }, answered).ask).toBeNull()
})
})
@@ -8,6 +8,7 @@ import { parseAgentQuestion } from './mobile-native-chat-question'
export type MobileNativeChatPrompts = {
permission: ReturnType<typeof detectAgentPermission>
question: ReturnType<typeof parseAgentQuestion>
detectedAsk: ReturnType<typeof parseAskFromStatus>
ask: ReturnType<typeof parseAskFromStatus>
}
@@ -42,9 +43,17 @@ export function useMobileNativeChatPrompts(args: {
[askFromStatus, messages]
)
const detectedAsk = askFromStatus ?? askFromMessages
return {
permission,
question,
ask: enabled ? (askFromStatus ?? askFromMessages) : null
detectedAsk: enabled ? detectedAsk : null,
// Only the status payload needs the paused gate the approval envelope uses:
// it outlives its answer, so a working/done agent must not surface one. The
// transcript fallback clears itself when the tool result lands, and it is the
// only source left once the hook row goes stale and projects to `done` with
// no interactivePrompt — gating it too strands a genuinely pending question.
ask: enabled ? ((blocked ? askFromStatus : null) ?? askFromMessages) : null
}
}
@@ -0,0 +1,279 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import {
clearMobileSessionOptionRecordsForTests,
useMobileNativeChatSessionOptions,
type MobileNativeChatSessionOptionsController
} from './use-mobile-native-chat-session-options'
type HookArgs = Parameters<typeof useMobileNativeChatSessionOptions>[0]
describe('useMobileNativeChatSessionOptions', () => {
let renderer: ReactTestRenderer | null = null
let api: MobileNativeChatSessionOptionsController | null = null
let hookArgs: HookArgs
const dispatchCommand = vi.fn<(command: string) => Promise<MobileNativeChatSendOutcome>>()
const onAgentPicker = vi.fn()
function Probe(): null {
api = useMobileNativeChatSessionOptions(hookArgs)
return null
}
const mount = (overrides: Partial<HookArgs> = {}): void => {
hookArgs = {
agent: 'claude',
scopeKey: 'host\0worktree\0tab',
reportedModel: null,
dispatchCommand,
onAgentPicker,
...overrides
}
act(() => {
renderer = create(createElement(Probe))
})
}
const update = (overrides: Partial<HookArgs>): void => {
hookArgs = { ...hookArgs, ...overrides }
act(() => {
renderer!.update(createElement(Probe))
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
clearMobileSessionOptionRecordsForTests()
dispatchCommand.mockReset()
dispatchCommand.mockResolvedValue('accepted')
onAgentPicker.mockReset()
})
afterEach(() => {
act(() => {
renderer?.unmount()
})
renderer = null
api = null
})
it('serves the shared catalog snapshot for the agent', () => {
mount()
expect(api!.snapshot[0]).toMatchObject({ id: 'model', category: 'model' })
expect(api!.snapshot[0]!.kind).toMatchObject({ type: 'select' })
})
it('returns an empty snapshot for agents without a catalog', () => {
mount({ agent: 'amp' })
expect(api!.snapshot).toEqual([])
})
it('does not expose catalog-backed agents outside the Claude and Codex scope', () => {
mount({ agent: 'gemini' })
expect(api!.snapshot).toEqual([])
})
it('applies a model pick through the catalog modelApply command', async () => {
mount()
let applied: boolean | undefined
await act(async () => {
applied = await api!.setOption('model', 'opus')
})
expect(applied).toBe(true)
expect(dispatchCommand).toHaveBeenCalledWith('/model opus')
const model = api!.snapshot[0]!
expect(model).toMatchObject({ valueSource: 'dispatched' })
expect(model.kind).toMatchObject({ currentValue: 'opus' })
})
it('keeps tracked truth when the dispatch is rejected', async () => {
dispatchCommand.mockResolvedValue('rejected')
mount()
let applied: boolean | undefined
await act(async () => {
applied = await api!.setOption('model', 'opus')
})
expect(applied).toBe(false)
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
})
it('routes Codex model changes through the agent picker action', async () => {
mount({ agent: 'codex' })
expect(api!.snapshot[0]).toMatchObject({ action: { type: 'agent-picker' } })
await act(async () => {
await api!.invokeAction('model')
})
expect(dispatchCommand).toHaveBeenCalledWith('/model')
expect(onAgentPicker).toHaveBeenCalledTimes(1)
})
it('seeds the current model from a hook-reported provider model', () => {
mount({ reportedModel: 'claude-sonnet-5' })
const model = api!.snapshot[0]!
expect(model).toMatchObject({ valueSource: 'reported' })
expect(model.kind).toMatchObject({ currentValue: 'sonnet' })
})
it('tracks typed commands via recordCommand', () => {
mount()
act(() => {
api!.recordCommand('/model haiku')
})
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'haiku' })
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
})
it('applies an option under the tracked model and scopes it to that model', async () => {
mount({ reportedModel: 'claude-sonnet-5' })
await act(async () => {
await api!.setOption('effort', 'low')
})
expect(dispatchCommand).toHaveBeenCalledWith('/effort low')
const effort = api!.snapshot.find((descriptor) => descriptor.id === 'effort')
expect(effort).toMatchObject({ valueSource: 'dispatched' })
expect(effort!.kind).toMatchObject({ currentValue: 'low' })
})
it('does not file an option under a model that changed mid-dispatch', async () => {
const resolvers: ((outcome: MobileNativeChatSendOutcome) => void)[] = []
dispatchCommand.mockImplementation(
() => new Promise<MobileNativeChatSendOutcome>((resolve) => resolvers.push(resolve))
)
mount({ reportedModel: 'claude-sonnet-5' })
let applied!: Promise<boolean>
await act(async () => {
applied = api!.setOption('effort', 'low')
await Promise.resolve()
})
// A report lands while `/effort low` is still in flight and moves the model.
update({ reportedModel: 'claude-opus-5' })
await act(async () => {
resolvers[0]!('accepted')
await applied
})
// The effort must not be recorded against Opus — it was sent for Sonnet.
const effort = api!.snapshot.find((descriptor) => descriptor.id === 'effort')
expect(effort?.kind).not.toMatchObject({ currentValue: 'low' })
})
it('does not revive a stale session-start report over a newer local pick', async () => {
mount({ reportedModel: 'claude-sonnet-5' })
await act(async () => {
await api!.setOption('model', 'opus')
})
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' })
// Leaving the tab and returning re-delivers the SAME session-start report,
// which cannot have observed the `/model opus` sent after it.
update({ scopeKey: 'host\0worktree\0other' })
update({ scopeKey: 'host\0worktree\0tab' })
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' })
})
it('still lets a genuinely new report supersede a local pick', async () => {
mount({ reportedModel: 'claude-sonnet-5' })
await act(async () => {
await api!.setOption('model', 'opus')
})
update({ reportedModel: 'claude-haiku-4-5' })
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'reported' })
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'haiku' })
})
it('keeps the live tab’s tracked model when other tabs overflow the record cap', async () => {
mount()
await act(async () => {
await api!.setOption('model', 'opus')
})
// Far more scopes than the cap, revisiting the live tab in between the way a
// chat↔terminal flip does — insertion-order eviction would shed it.
for (let index = 0; index < 40; index += 1) {
update({ scopeKey: `host\0worktree\0overflow-${index}` })
update({ scopeKey: 'host\0worktree\0tab' })
}
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' })
})
it('keeps the latest queued operation pending until it settles', async () => {
const resolvers: ((outcome: MobileNativeChatSendOutcome) => void)[] = []
dispatchCommand.mockImplementation(
() =>
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolvers.push(resolve)
})
)
mount({ reportedModel: 'claude-sonnet-5' })
let first!: Promise<boolean>
let second!: Promise<boolean>
await act(async () => {
first = api!.setOption('effort', 'low')
second = api!.setOption('model', 'opus')
await Promise.resolve()
})
expect(api!.pendingId).toBe('model')
await act(async () => {
resolvers[0]!('accepted')
await Promise.resolve()
})
expect(dispatchCommand).toHaveBeenCalledTimes(2)
expect(api!.pendingId).toBe('model')
await act(async () => {
resolvers[1]!('accepted')
await Promise.all([first, second])
})
expect(api!.pendingId).toBeNull()
})
it('does not dispatch a queued option into a newly active tab', async () => {
let resolveFirst!: (outcome: MobileNativeChatSendOutcome) => void
dispatchCommand.mockImplementationOnce(
() =>
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolveFirst = resolve
})
)
mount({ reportedModel: 'claude-sonnet-5' })
let first!: Promise<boolean>
let queued!: Promise<boolean>
await act(async () => {
first = api!.setOption('effort', 'low')
queued = api!.setOption('model', 'opus')
await Promise.resolve()
})
update({ scopeKey: 'host\0worktree\0other-tab' })
await act(async () => {
resolveFirst('accepted')
await first
})
await expect(queued).resolves.toBe(false)
expect(dispatchCommand).toHaveBeenCalledTimes(1)
})
it('does not dispatch queued work after unmount', async () => {
let resolveFirst!: (outcome: MobileNativeChatSendOutcome) => void
dispatchCommand.mockImplementationOnce(
() =>
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolveFirst = resolve
})
)
mount({ reportedModel: 'claude-sonnet-5' })
let first!: Promise<boolean>
let queued!: Promise<boolean>
await act(async () => {
first = api!.setOption('effort', 'low')
queued = api!.setOption('model', 'opus')
await Promise.resolve()
})
act(() => {
renderer!.unmount()
})
renderer = null
resolveFirst('accepted')
await expect(first).resolves.toBe(true)
await expect(queued).resolves.toBe(false)
expect(dispatchCommand).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
getAgentSessionOptionCatalog,
type AgentSessionOptionCatalog,
type CatalogModel
} from '../../../src/shared/agent-session-option-catalog'
import type {
SessionOptionDescriptor,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import {
buildNativeChatSessionOptionCommand,
recordNativeChatSessionOptionCommand
} from '../../../src/shared/native-chat-session-option-commands'
import {
buildNativeChatSessionOptionSnapshot,
withTrackedNativeChatModel
} from '../../../src/shared/native-chat-session-option-snapshot'
import {
applyNativeChatReportedSessionOptions,
clearNativeChatSessionModel,
createNativeChatSessionOptionRecord,
getTrackedSessionOption,
isFlipOnlyMidSession,
matchNativeChatCatalogModelId,
setTrackedSessionOption,
type NativeChatSessionOptionRecord
} from '../../../src/shared/native-chat-session-option-state'
export type MobileNativeChatSessionOptionsController = {
/** Model descriptor first, then the current model's options; empty when the
* agent has no catalog. */
snapshot: SessionOptionDescriptor[]
/** Descriptor id with a dispatch in flight; the UI disables rows meanwhile. */
pendingId: string | null
setOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeAction: (id: string) => Promise<boolean>
/** Track a slash command the user typed themselves (e.g. `/model sonnet`). */
recordCommand: (command: string) => void
}
type PendingOperation = { id: string; token: number }
// Why: per-tab records survive chat↔terminal flips and remounts, like desktop's
// scope cache. Bounded so long sessions across many tabs can't grow unbounded.
const MOBILE_SESSION_OPTION_RECORD_CAP = 32
const recordsByScope = new Map<string, NativeChatSessionOptionRecord>()
// The catalog model id last taken from a hook report, per scope. Mobile cannot
// read the agent's screen, so a repeat of the same report is not new evidence.
const appliedReportByScope = new Map<string, string>()
function getScopedRecord(scopeKey: string, agent: string): NativeChatSessionOptionRecord {
const existing = recordsByScope.get(scopeKey)
const record =
existing && existing.agent === agent ? existing : createNativeChatSessionOptionRecord(agent)
if (record !== existing) {
appliedReportByScope.delete(scopeKey)
}
// Why: delete-then-set on every read makes the touched scope most-recent, so
// eviction only sheds the oldest UNTOUCHED tab. Insertion order alone would let
// a long-lived active tab be the oldest key and lose its tracked model.
recordsByScope.delete(scopeKey)
recordsByScope.set(scopeKey, record)
while (recordsByScope.size > MOBILE_SESSION_OPTION_RECORD_CAP) {
const oldest = recordsByScope.keys().next().value
if (oldest === undefined) {
break
}
recordsByScope.delete(oldest)
appliedReportByScope.delete(oldest)
}
return record
}
export function clearMobileSessionOptionRecordsForTests(): void {
recordsByScope.clear()
appliedReportByScope.clear()
}
const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = []
/** The model list every consumer must see: the catalog's, plus the tracked model
* when the catalog no longer lists it. Desktop reconciles identically. */
function activeModels(
catalog: AgentSessionOptionCatalog,
record: NativeChatSessionOptionRecord
): CatalogModel[] {
return withTrackedNativeChatModel(catalog, catalog.models, record)
}
export function useMobileNativeChatSessionOptions(args: {
agent: string | null
/** Stable per-tab scope (host + worktree + tab), or null when no tab is active. */
scopeKey: string | null
/** Provider model from live agent status, when the hook reported one. */
reportedModel: string | null
dispatchCommand: (command: string) => Promise<MobileNativeChatSendOutcome>
/** A model change that must happen in the agent's own TUI picker was
* dispatched — bring the terminal view forward. */
onAgentPicker?: () => void
}): MobileNativeChatSessionOptionsController {
const { agent, scopeKey, reportedModel, dispatchCommand, onAgentPicker } = args
const catalog = useMemo(
() => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null),
[agent]
)
const identity = agent && scopeKey ? `${scopeKey}\0${agent}` : null
const [version, setVersion] = useState(0)
const [pendingByIdentity, setPendingByIdentity] = useState<
Record<string, PendingOperation | undefined>
>({})
const pendingId = identity ? (pendingByIdentity[identity]?.id ?? null) : null
const bump = useCallback(() => setVersion((current) => current + 1), [])
const activeIdentityRef = useRef(identity)
const applyQueuesRef = useRef(new Map<string, Promise<void>>())
const operationTokenRef = useRef(0)
useLayoutEffect(() => {
activeIdentityRef.current = identity
return () => {
activeIdentityRef.current = null
}
}, [identity])
// Seed the current model from live agent status; hook reports are authority
// over locally dispatched guesses (desktop 'reported' source parity).
useEffect(() => {
if (!catalog || !scopeKey || !agent || !reportedModel) {
return
}
const matched = matchNativeChatCatalogModelId(catalog, reportedModel)
if (!matched) {
return
}
// Why: the same report is re-delivered whenever the tab is re-entered or the
// status stream reconnects, and a session-start report cannot have observed a
// `/model` sent after it. Re-applying it would revert the user's pick. Only a
// report that CHANGES is evidence; the value itself still wins when it does.
if (appliedReportByScope.get(scopeKey) === matched) {
return
}
appliedReportByScope.set(scopeKey, matched)
const record = getScopedRecord(scopeKey, agent)
if (applyNativeChatReportedSessionOptions(record, { model: matched })) {
bump()
}
}, [agent, bump, catalog, reportedModel, scopeKey])
const snapshot = useMemo(() => {
if (!catalog || !scopeKey || !agent) {
return EMPTY_SNAPSHOT
}
// Why: `version` invalidates this memo after in-place record mutations.
void version
const record = getScopedRecord(scopeKey, agent)
return buildNativeChatSessionOptionSnapshot({
catalog,
// The snapshot no longer self-heals an unlisted tracked model; every caller
// reconciles it in, so a value the seed dropped keeps its row and options.
models: activeModels(catalog, record),
record,
mode: 'live',
modelLabel: 'Model'
})
}, [agent, catalog, scopeKey, version])
const runSerialized = useCallback(
(operationIdentity: string, id: string, run: () => Promise<boolean>): Promise<boolean> => {
const previous = applyQueuesRef.current.get(operationIdentity) ?? Promise.resolve()
const runIfCurrent = (): Promise<boolean> =>
activeIdentityRef.current === operationIdentity ? run() : Promise.resolve(false)
const chained = previous.then(runIfCurrent, runIfCurrent)
const tail = chained.then(
() => undefined,
() => undefined
)
applyQueuesRef.current.set(operationIdentity, tail)
operationTokenRef.current += 1
const token = operationTokenRef.current
setPendingByIdentity((current) => ({
...current,
[operationIdentity]: { id, token }
}))
void tail.then(() => {
if (applyQueuesRef.current.get(operationIdentity) === tail) {
applyQueuesRef.current.delete(operationIdentity)
}
setPendingByIdentity((current) => {
if (current[operationIdentity]?.token !== token) {
return current
}
const next = { ...current }
delete next[operationIdentity]
return next
})
})
return chained
},
[]
)
const setOption = useCallback(
(id: string, value: SessionOptionValue): Promise<boolean> => {
if (!catalog || !scopeKey || !agent || !identity) {
return Promise.resolve(false)
}
return runSerialized(identity, id, async () => {
const record = getScopedRecord(scopeKey, agent)
const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null
const apply =
id === 'model'
? catalog.modelApply
: activeModels(catalog, record)
.find((model) => model.id === previousModelId)
?.options.find((option) => option.id === id)?.apply
if (!apply || apply.midSession?.kind === 'agent-picker') {
return false
}
const flipOnly = isFlipOnlyMidSession(apply.midSession)
const trackedToggle = flipOnly
? getTrackedSessionOption(record, previousModelId, id)
: undefined
if (flipOnly && !trackedToggle) {
// Why: a flip from an unknown baseline cannot honor an absolute target.
return false
}
// Why: same absolute target must never re-dispatch a flip (would invert the agent).
if (flipOnly && trackedToggle?.value === value) {
return true
}
const command = buildNativeChatSessionOptionCommand({
optionId: id,
value,
apply,
modelId: previousModelId,
catalog,
models: activeModels(catalog, record),
record
})
if (!command) {
return false
}
// Baseline for detecting a hook report or typed command that lands while
// the dispatch is in flight — the record is shared mutable state and the
// report effect is not on this queue.
const trackedBeforeDispatch =
id === 'model' ? undefined : getTrackedSessionOption(record, previousModelId, id)
const outcome = await dispatchCommand(command)
if (outcome === 'rejected') {
return false
}
if (id !== 'model') {
// Why (desktop parity): `setTrackedSessionOption` resolves the owning
// model when it commits, not when the command was built. If the model
// moved during the dispatch, committing now would file this value under
// the NEW model — claiming an effort the agent was never asked for.
const modelStill = typeof record.model?.value === 'string' ? record.model.value : null
if (
modelStill !== previousModelId ||
getTrackedSessionOption(record, previousModelId, id) !== trackedBeforeDispatch
) {
return true
}
}
if (id === 'model') {
if (typeof value === 'string' && previousModelId !== value) {
// Why: switching models can reset effort/toggles for the destination model.
delete record.valuesByModel[value]
}
}
// Why: flip-only never heals via agent report — track as applied best-known.
setTrackedSessionOption(record, id, value, flipOnly ? 'applied' : 'dispatched')
bump()
return true
})
},
[agent, bump, catalog, dispatchCommand, identity, runSerialized, scopeKey]
)
const invokeAction = useCallback(
(id: string): Promise<boolean> => {
if (!catalog || !scopeKey || !agent || !identity) {
return Promise.resolve(false)
}
return runSerialized(identity, id, async () => {
const record = getScopedRecord(scopeKey, agent)
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
const apply =
id === 'model'
? catalog.modelApply
: activeModels(catalog, record)
.find((model) => model.id === modelId)
?.options.find((option) => option.id === id)?.apply
const midSession = apply?.midSession
if (midSession?.kind === 'agent-picker') {
const outcome = await dispatchCommand(midSession.command)
if (outcome === 'rejected') {
return false
}
clearNativeChatSessionModel(record)
bump()
onAgentPicker?.()
return true
}
if (isFlipOnlyMidSession(midSession) && !getTrackedSessionOption(record, modelId, id)) {
// Why: an unknown baseline remains unknown after one inversion.
return (await dispatchCommand(midSession.command)) !== 'rejected'
}
return false
})
},
[agent, bump, catalog, dispatchCommand, identity, onAgentPicker, runSerialized, scopeKey]
)
const recordCommand = useCallback(
(command: string): void => {
if (!catalog || !scopeKey || !agent) {
return
}
const record = getScopedRecord(scopeKey, agent)
const result = recordNativeChatSessionOptionCommand({
catalog,
models: activeModels(catalog, record),
record,
command
})
if (result.changed) {
bump()
}
if (result.opensAgentPicker) {
onAgentPicker?.()
}
},
[agent, bump, catalog, onAgentPicker, scopeKey]
)
return useMemo(
() => ({ snapshot, pendingId, setOption, invokeAction, recordCommand }),
[snapshot, pendingId, setOption, invokeAction, recordCommand]
)
}
@@ -0,0 +1,168 @@
import { createElement, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { MobileGitHistoryList } from './MobileGitHistoryList'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
FlatList: ({
data,
renderItem
}: {
data: { id: string }[]
renderItem: (info: { item: { id: string } }) => ReactElement
}) =>
createElement(
'FlatList',
null,
data.map((item) => createElement('Row', { key: item.id }, renderItem({ item })))
),
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', ChevronRight: 'ChevronRight' }))
vi.mock('../transport/client-context', () => ({ useForceReconnect: () => vi.fn() }))
function historyResponse(subject: string) {
return {
ok: true,
result: {
items: [{ id: 'commit-1', displayId: 'c0mm1t1', subject, author: 'Ada', parentIds: [] }]
}
}
}
const compareResponse = {
ok: true,
result: { entries: [{ path: 'src/app.ts', added: 3, removed: 1 }] }
}
describe('MobileGitHistoryList', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function listElement(client: RpcClient | null, connState: ConnectionState) {
return createElement(MobileGitHistoryList, {
client,
connState,
worktreeId: 'wt-1',
hostId: 'host-1',
bottomInset: 0
})
}
async function render(client: RpcClient, connState: ConnectionState): Promise<void> {
await act(async () => {
renderer = create(listElement(client, connState))
await Promise.resolve()
})
}
async function update(client: RpcClient | null, connState: ConnectionState): Promise<void> {
await act(async () => {
renderer?.update(listElement(client, connState))
await Promise.resolve()
})
}
function tree(): string {
return JSON.stringify(renderer?.toJSON())
}
it('keeps loaded commits visible across a disconnect and its reconnect refetch', async () => {
let releaseRefetch: (() => void) | null = null
const sendRequest = vi
.fn()
.mockResolvedValueOnce(historyResponse('first load'))
.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseRefetch = () => resolve(historyResponse('after reconnect'))
})
)
const client = { sendRequest } as unknown as RpcClient
await render(client, 'connected')
expect(tree()).toContain('first load')
await update(client, 'reconnecting')
expect(tree()).toContain('first load')
// The refetch is in flight: old rows must stay up instead of flashing empty.
await update(client, 'connected')
expect(tree()).toContain('first load')
await act(async () => {
releaseRefetch?.()
await Promise.resolve()
})
expect(tree()).toContain('after reconnect')
expect(sendRequest).toHaveBeenCalledTimes(2)
})
it('wipes commits when the worktree identity changes', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce(historyResponse('worktree one'))
.mockReturnValueOnce(new Promise(() => {}))
const client = { sendRequest } as unknown as RpcClient
await render(client, 'connected')
expect(tree()).toContain('worktree one')
await act(async () => {
renderer?.update(
createElement(MobileGitHistoryList, {
client,
connState: 'connected',
worktreeId: 'wt-2',
hostId: 'host-1',
bottomInset: 0
})
)
})
expect(tree()).not.toContain('worktree one')
})
it('refetches the expanded commit files after a reconnect instead of caching the outage answer', async () => {
const sendRequest = vi.fn().mockImplementation((method: string) => {
if (method === 'git.history') {
return Promise.resolve(historyResponse('expandable'))
}
return Promise.resolve(compareResponse)
})
const client = { sendRequest } as unknown as RpcClient
await render(client, 'connected')
await update(client, 'disconnected')
const row = renderer?.root.findAll(
(node) => node.type === 'Pressable' && node.props.onPress !== undefined
)[0]
await act(async () => {
row?.props.onPress()
})
// Offline expand cannot request anything, so nothing is cached as "no file changes".
expect(tree()).toContain('Waiting for desktop...')
expect(sendRequest).toHaveBeenCalledTimes(1)
await update(client, 'connected')
expect(sendRequest).toHaveBeenCalledWith('git.commitCompare', {
worktree: 'id:wt-1',
commitId: 'commit-1'
})
expect(tree()).toContain('src/app.ts')
})
})
@@ -44,14 +44,14 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
const [expanded, setExpanded] = useState<string | null>(null)
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
// Worktree identity change must wipe history immediately — even while
// Host or worktree identity change must wipe history immediately — even while
// disconnected — so a kept-mounted hub segment never shows another tree's commits.
useEffect(() => {
setRows(null)
setError(null)
setExpanded(null)
setFilesById({})
}, [worktreeId])
}, [hostId, worktreeId])
useEffect(() => {
let active = true
@@ -60,12 +60,9 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
// resolveMobileHistoryScreenView keeps them visible (STA-1511).
return
}
// Reset prior error/rows so a successful retry doesn't stay stuck behind a
// stale error (error wins render precedence).
// Why (F10): clear only the error (it wins render precedence, so a stale one would outlive a
// successful retry) — the loaded rows stay up until fresh ones land instead of flashing empty.
setError(null)
setRows(null)
setExpanded(null)
setFilesById({})
void (async () => {
try {
const result = await fetchMobileGitHistory(client, worktreeId)
@@ -96,45 +93,43 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
setReloadNonce((n) => n + 1)
}, [connState, forceReconnect, hostId])
const toggleCommit = useCallback(
(row: MobileCommitRow) => {
const next = expanded === row.id ? null : row.id
setExpanded(next)
if (next && !filesById[row.id]) {
// No client (disconnected while cached rows stay visible): resolve to an
// empty file list so the row shows "No file changes" instead of a spinner
// that never completes — no request can be made.
if (!client) {
setFilesById((prev) => ({ ...prev, [row.id]: [] }))
return
const toggleCommit = useCallback((row: MobileCommitRow) => {
setExpanded((current) => (current === row.id ? null : row.id))
}, [])
// Why (F10): the expanded commit's files load here, not in the tap handler, so a row expanded
// during an outage refetches on reconnect instead of caching the outage's answer forever.
useEffect(() => {
if (!expanded || !client || connState !== 'connected') {
return
}
const commitId = expanded
let stale = false
setFilesById((prev) => (prev[commitId] ? prev : { ...prev, [commitId]: 'loading' }))
void client
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId })
.then((response) => {
const entries = response.ok
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
: []
if (!stale) {
setFilesById((prev) => ({ ...prev, [commitId]: entries }))
}
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
void client
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
.then((response) => {
const entries = response.ok
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
: []
setFilesById((prev) => {
// Drop stale responses if the row is no longer loading (collapsed + re-opened).
if (prev[row.id] !== 'loading') {
return prev
}
return { ...prev, [row.id]: entries }
})
})
.catch(() =>
setFilesById((prev) => {
if (prev[row.id] !== 'loading') {
return prev
}
return { ...prev, [row.id]: [] }
})
})
.catch(() => {
// Keep an already-loaded list; a first load that fails resolves to "No file changes".
if (!stale) {
setFilesById((prev) =>
prev[commitId] === 'loading' ? { ...prev, [commitId]: [] } : prev
)
}
},
[client, expanded, filesById, worktreeId]
)
}
})
return () => {
stale = true
}
}, [client, connState, expanded, worktreeId])
const connected = client !== null && connState === 'connected'
const renderCommit = useCallback(
({ item }: { item: MobileCommitRow }) => {
@@ -163,7 +158,12 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
{isOpen ? (
<View style={styles.files}>
{files === 'loading' || files === undefined ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
// No request can complete while disconnected, so say so instead of spinning forever.
connected ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Text style={styles.empty}>Waiting for desktop...</Text>
)
) : files.length === 0 ? (
<Text style={styles.empty}>No file changes</Text>
) : (
@@ -184,14 +184,10 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
</View>
)
},
[expanded, filesById, toggleCommit]
[connected, expanded, filesById, toggleCommit]
)
const view = resolveMobileHistoryScreenView({
connected: client !== null && connState === 'connected',
rows,
error
})
const view = resolveMobileHistoryScreenView({ connected, rows, error })
if (view.kind === 'error' || view.kind === 'waiting') {
return (
+74 -4
View File
@@ -204,7 +204,7 @@ describe('useHostClient', () => {
}
})
it('stays disconnected while a reused screen resolves an uncached host', async () => {
it('shows connecting while a reused screen resolves an uncached host', async () => {
const client = makeFakeClient('connected')
connectMock.mockReturnValue(client)
loadHostsMock.mockResolvedValueOnce([HOST]).mockReturnValueOnce(new Promise<never>(() => {}))
@@ -226,18 +226,20 @@ describe('useHostClient', () => {
})
expect(stateByRenderTick.get(0)).toBe('connected')
// Why (S2): the unresolved-open window is amber, not grey — 'disconnected'
// here made every host swap flash a dead host while the Keychain read ran.
selectedHostId = 'missing-host'
renderTick = 1
await act(async () => {
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
})
expect(stateByRenderTick.get(1)).toBe('disconnected')
expect(stateByRenderTick.get(1)).toBe('connecting')
renderTick = 2
await act(async () => {
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
})
expect(stateByRenderTick.get(2)).toBe('disconnected')
expect(stateByRenderTick.get(2)).toBe('connecting')
} finally {
restore()
act(() => renderer?.unmount())
@@ -330,7 +332,7 @@ describe('useHostClient', () => {
expect(stale.closeMock).toHaveBeenCalledOnce()
expect(harness.hook.client).toBeNull()
expect(harness.hook.state).toBe('disconnected')
expect(harness.hook.state).toBe('connecting')
expect(connectMock).toHaveBeenCalledOnce()
await act(async () => {
@@ -778,6 +780,74 @@ describe('useHostClient', () => {
harness.unmount()
})
it('seeds connecting during the async open instead of flashing disconnected', async () => {
let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null
const hostLookup = new Promise<(typeof HOST)[]>((resolve) => {
resolveHosts = resolve
})
connectMock.mockReturnValue(makeFakeClient('connecting'))
loadHostsMock.mockReturnValue(hostLookup)
const states: ConnectionState[] = []
let renderer: ReactTestRenderer | null = null
function Probe(): null {
states.push(useHostClient(HOST.id).state)
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
})
expect(states.at(-1)).toBe('connecting')
await act(async () => {
resolveHosts?.([HOST])
await hostLookup
})
expect(states.at(-1)).toBe('connecting')
expect(states).not.toContain('disconnected')
} finally {
restore()
act(() => renderer?.unmount())
}
})
it('keeps Retry amber through forceReconnect instead of grey-then-amber', async () => {
const first = makeFakeClient('connected')
const second = makeFakeClient('connecting')
second.sendRequest = vi.fn(async () => ({ id: 'health', ok: true, result: {} }))
connectMock.mockReturnValueOnce(first).mockReturnValueOnce(second)
loadHostsMock.mockResolvedValue([HOST])
const states: ConnectionState[] = []
let forceReconnect: ((hostId: string) => Promise<void>) | null = null
let renderer: ReactTestRenderer | null = null
function Probe(): null {
forceReconnect = useForceReconnect()
states.push(useHostClient(HOST.id).state)
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(states.at(-1)).toBe('connected')
await act(async () => {
await forceReconnect?.(HOST.id)
})
expect(first.closeMock).toHaveBeenCalled()
expect(states.at(-1)).toBe('connecting')
expect(states).not.toContain('disconnected')
} finally {
restore()
act(() => renderer?.unmount())
}
})
it('does not open a client after the host is closed during an in-flight lookup', async () => {
let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null
const hostLookup = new Promise<(typeof HOST)[]>((resolve) => {
+22 -67
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react'
import type { RpcClient } from './rpc-client'
import { connectionLogStore } from './connection-log-buffer'
import { HostForceReconnectCoordinator, type HostReconnectEntry } from './host-force-reconnect'
@@ -14,7 +14,7 @@ import type { RpcClientContextValue } from './rpc-client-context-contract'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionLogSink, ConnectionState, HostProfile } from './types'
import { RpcApplicationResponsiveness } from './rpc-application-responsiveness'
import { RpcClientContextBoundary, useRpcClientContext } from './rpc-client-react-context'
import { RpcClientContextBoundary } from './rpc-client-react-context'
export { useRpcClientContext } from './rpc-client-react-context'
type StoreEntry = HostReconnectEntry & { state: ConnectionState }
@@ -89,6 +89,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
resolve = res
})
const pendingOpen = pendingOpensRef.current.register(hostId, profileVersion, promise)
notifyHostState(hostId, 'connecting')
try {
const onUnavailable = () => {
@@ -250,10 +251,22 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
[runForceReconnect]
)
const getState = useCallback((hostId: string): ConnectionState => {
return storeRef.current.get(hostId)?.state ?? 'disconnected'
// null = no entry and no open in flight; callers pick their own default.
const getKnownState = useCallback((hostId: string): ConnectionState | null => {
const entry = storeRef.current.get(hostId)
if (entry) {
return entry.state
}
// Why: the async open (a Keychain pass) predates the store entry; reading that
// window as 'disconnected' made every host screen flash dead on mount (S2).
return pendingOpensRef.current.hasActive(hostId) ? 'connecting' : null
}, [])
const getState = useCallback(
(hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected',
[getKnownState]
)
const getReconnectAttempt = useCallback((hostId: string): number => {
return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0
}, [])
@@ -323,9 +336,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}, [])
useEffect(() => {
return subscribeConnectionRevivalTriggers(() => {
return subscribeConnectionRevivalTriggers((reason) => {
for (const entry of storeRef.current.values()) {
entry.client.notifyForeground()
entry.client.notifyForeground(reason)
}
})
}, [])
@@ -338,6 +351,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
forceReconnectAfterEdit,
closeHost: closeEntry,
getState,
getKnownState,
getReconnectAttempt,
getLastConnectedAt,
getRpcUnresponsiveSince,
@@ -354,6 +368,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
forceReconnectAfterEdit,
closeEntry,
getState,
getKnownState,
getReconnectAttempt,
getLastConnectedAt,
getRpcUnresponsiveSince,
@@ -368,67 +383,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return <RpcClientContextBoundary value={value}>{children}</RpcClientContextBoundary>
}
export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
} {
const ctx = useRpcClientContext()
const [, force] = useState(0)
const [state, setState] = useState<ConnectionState>(() =>
hostId ? ctx.getState(hostId) : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
const clientHostIdRef = useRef<string | undefined>(hostId)
useEffect(() => {
if (!hostId) {
clientRef.current = null
clientHostIdRef.current = undefined
setState('disconnected')
return
}
clientHostIdRef.current = hostId
let cancelled = false
// Subscribe before acquire so any state change during open is captured.
const unsub = ctx.subscribeHostState(hostId, (next) => {
if (cancelled) {
return
}
setState(next)
// Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one.
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
if (found && found.client !== clientRef.current) {
clientRef.current = found.client
force((n) => n + 1)
} else if (!found && clientRef.current) {
// Why: closeHost deletes the entry with no replacement; null it so screens don't drive a dead client (STA-1511).
clientRef.current = null
force((n) => n + 1)
}
})
const initial = ctx.acquire(hostId)
clientRef.current = initial
setState(ctx.getState(hostId))
if (initial) {
// Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client.
force((n) => n + 1)
}
return () => {
cancelled = true
unsub()
ctx.release(hostId)
clientRef.current = null
clientHostIdRef.current = undefined
}
}, [ctx, hostId])
// Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render.
const bound = clientHostIdRef.current === hostId
const boundState = bound ? state : hostId ? ctx.getState(hostId) : 'disconnected'
return { client: bound ? clientRef.current : null, state: boundState }
}
// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
export { useHostClient } from './use-host-client'
export {
useCloseHost,
useForceReconnect,
@@ -6,10 +6,12 @@ import { addNetworkStateListener, getNetworkStateAsync, type NetworkState } from
// without an onclose. Both leave clients waiting out long backoff timers or
// parked at the reconnect give-up cap (issue #5049). Surface every "the link
// probably just came back" OS signal as a single nudge callback.
export function subscribeConnectionRevivalTriggers(nudge: () => void): () => void {
export function subscribeConnectionRevivalTriggers(
nudge: (reason: 'app-resume' | 'network-change') => void
): () => void {
const appStateSub = AppState.addEventListener('change', (next) => {
if (next === 'active') {
nudge()
nudge('app-resume')
}
})
let lastNetwork: Pick<NetworkState, 'isConnected' | 'type'> | null = null
@@ -39,7 +41,7 @@ export function subscribeConnectionRevivalTriggers(nudge: () => void): () => voi
type: state.type,
cameOnline
})
nudge()
nudge('network-change')
}
})
return () => {
@@ -14,6 +14,11 @@ export class HostClientOpenRegistry {
: null
}
hasActive(hostId: string): boolean {
const ticket = this.pending.get(hostId)
return Boolean(ticket && !ticket.cancelled)
}
register(hostId: string, profileVersion: number, promise: Promise<void>): HostClientOpenTicket {
const previous = this.pending.get(hostId)
if (previous) {
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
mobileHostEditHostRoute,
mobileHostEditRoute,
mobileHostEditRouteTarget,
navigateToMobileHostEdit,
type MobileHostEditNavigationState
} from './host-edit-navigation'
@@ -15,6 +15,7 @@ function navigationHarness(initialState: MobileHostEditNavigationState) {
stateListener = listener
return unsubscribeState
}),
dispatch: vi.fn(),
getState: () => state
}
return {
@@ -27,51 +28,83 @@ function navigationHarness(initialState: MobileHostEditNavigationState) {
}
}
// Edit now waits for the nested host stack, not just the root `h` route, so every committed
// state below carries the stack the replacement targets.
function committedHostState(hostIdParam: string): MobileHostEditNavigationState {
return {
index: 1,
routes: [
{ name: 'index' },
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: hostIdParam } }]
}
}
]
}
}
describe('mobile host edit navigation', () => {
it('waits for the expected host route to commit before replacing it with Edit', () => {
it('waits for the expected host stack to commit before replacing it with Edit', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const replace = vi.fn()
navigateToMobileHostEdit(harness.navigation, { push, replace }, 'host/1')
navigateToMobileHostEdit(harness.navigation, { push }, 'host/1')
expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1'))
expect(replace).not.toHaveBeenCalled()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({
index: 1,
routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }]
})
// The host route commits before its stack mounts; the old root-only predicate fired here.
harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'h' }] })
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState(committedHostState('host%2F1'))
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host/1'))
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'REPLACE',
target: '/h',
source: 'host-index',
payload: mobileHostEditRouteTarget('host/1')
})
})
it('does not replace an unrelated host route', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const replace = vi.fn()
navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace }, 'host-1')
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1')
harness.setState(committedHostState('host-2'))
expect(replace).not.toHaveBeenCalled()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
})
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')
// Enter the host flow for a different host, then leave it entirely.
harness.setState(committedHostState('host-2'))
harness.setState({ index: 0, routes: [{ name: 'index' }] })
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
// A late matching commit must not resurrect the replacement.
harness.setState(committedHostState('host-1'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
})
it('cancels a pending replacement when navigation leaves the host flow', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const replace = vi.fn()
const controller = navigateToMobileHostEdit(
harness.navigation,
{ push: vi.fn(), replace },
'host-1'
)
const controller = navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1')
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
harness.setState(committedHostState('host-2'))
controller.cancel()
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
harness.setState(committedHostState('host-1'))
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(replace).not.toHaveBeenCalled()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
})
it('unsubscribes when mounting the host throws synchronously', () => {
@@ -84,8 +117,7 @@ describe('mobile host edit navigation', () => {
{
push: () => {
throw error
},
replace: vi.fn()
}
},
'host-1'
)
+20 -66
View File
@@ -1,34 +1,27 @@
export type MobileHostEditNavigationState = Readonly<{
index: number
routes: readonly MobileHostEditNavigationRoute[]
}>
import {
hostStackHostRoute,
navigateToHostStackRoute,
type HostStackHostRoute,
type HostStackNavigationController,
type HostStackNavigationState,
type HostStackRootNavigation,
type HostStackRouteTarget,
type HostStackRouter
} from '../navigation/host-stack-navigation'
export type MobileHostEditNavigationRoute = Readonly<{
name: string
params?: Readonly<{ hostId?: unknown }>
}>
export type MobileHostEditHostRoute = HostStackHostRoute
export type MobileHostEditNavigationState = HostStackNavigationState
export type MobileHostEditRootNavigation = HostStackRootNavigation
export type MobileHostEditRouter = HostStackRouter
export type MobileHostEditNavigationController = HostStackNavigationController
export type MobileHostEditRootNavigation = {
addListener: (event: 'state', listener: () => void) => () => void
getState: () => MobileHostEditNavigationState
export function mobileHostEditHostRoute(hostId: string): MobileHostEditHostRoute {
return hostStackHostRoute(hostId)
}
export type MobileHostEditRouter = {
push: (href: `/h/${string}`) => void
replace: (href: ReturnType<typeof mobileHostEditRoute>) => void
}
export type MobileHostEditNavigationController = Readonly<{
cancel: () => void
}>
export function mobileHostEditHostRoute(hostId: string): `/h/${string}` {
return `/h/${encodeURIComponent(hostId)}`
}
export function mobileHostEditRoute(hostId: string) {
export function mobileHostEditRouteTarget(hostId: string): HostStackRouteTarget {
return {
pathname: '/h/[hostId]/edit' as const,
name: '[hostId]/edit',
params: { hostId }
}
}
@@ -38,44 +31,5 @@ export function navigateToMobileHostEdit(
router: MobileHostEditRouter,
hostId: string
): MobileHostEditNavigationController {
let active = true
let hostRouteSeen = false
let unsubscribeState = () => {}
const dispose = () => {
if (!active) {
return
}
active = false
unsubscribeState()
}
// Why: cold Expo deep links resolve to index; target Edit after the host route commits.
const onState = () => {
if (!active) {
return
}
const state = navigation.getState()
const currentRoute = state.routes[state.index]
if (currentRoute?.name !== 'h') {
if (hostRouteSeen) {
dispose()
}
return
}
hostRouteSeen = true
if (currentRoute.params?.hostId !== hostId) {
return
}
dispose()
router.replace(mobileHostEditRoute(hostId))
}
try {
unsubscribeState = navigation.addListener('state', onState)
router.push(mobileHostEditHostRoute(hostId))
} catch (error) {
dispose()
throw error
}
return { cancel: dispose }
return navigateToHostStackRoute(navigation, router, hostId, mobileHostEditRouteTarget(hostId))
}
+5 -3
View File
@@ -43,9 +43,11 @@ export function openHostLogicalClient(
closeLogical()
}
const notifyLogicalForeground = logical.notifyForeground
logical.notifyForeground = () => {
endpointLifecycle.setForeground(true)
notifyLogicalForeground()
logical.notifyForeground = (reason = 'focus') => {
// Why: a nudge while already foreground must not re-enter setForeground —
// that path suspended healthy relays; the supervisor probes or replaces instead.
endpointLifecycle.nudge(reason)
notifyLogicalForeground(reason)
}
return logical
}
+49 -4
View File
@@ -120,7 +120,7 @@ describe('useHostStatusGates', () => {
}
})
it('fails closed while the same client reconnects', async () => {
it('keeps the proven gates while the same client reconnects, pending until it re-answers', async () => {
let resolveReconnect: ((response: unknown) => void) | null = null
const pendingReconnect = new Promise((resolve) => {
resolveReconnect = resolve
@@ -152,12 +152,20 @@ describe('useHostStatusGates', () => {
await act(async () => {
renderer?.update(createElement(Probe, { connState: 'disconnected' }))
})
// Why (F10): the drop invalidates nothing the host already proved — capabilities survive it.
expect(gates).toMatchObject({
hostCapabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true,
statusPending: false
})
await act(async () => {
renderer?.update(createElement(Probe, { connState: 'connected' }))
})
expect(gates).toMatchObject({
hostCapabilities: [],
floatingWorkspaceEnabled: false
hostCapabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true,
statusPending: true
})
await act(async () => {
@@ -169,11 +177,48 @@ describe('useHostStatusGates', () => {
})
expect(gates).toMatchObject({
hostCapabilities: ['terminal.quick-commands.v1'],
floatingWorkspaceEnabled: true
floatingWorkspaceEnabled: true,
statusPending: false
})
} finally {
restore()
renderer?.unmount()
}
})
it('fails closed when the same host reconnects on a replaced client', async () => {
const firstClient = {
sendRequest: vi.fn().mockResolvedValue({
ok: true,
result: { capabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true }
})
} as unknown as RpcClient
const secondClient = {
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
} as unknown as RpcClient
let gates: HostStatusGates | null = null
let renderer: ReactTestRenderer | null = null
function Probe({ client }: { client: RpcClient }): null {
gates = useHostStatusGates({ hostId: 'host-1', client, connState: 'connected' })
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(Probe, { client: firstClient }))
await Promise.resolve()
})
expect(gates?.hostCapabilities).toEqual(['browser.screencast.v1'])
await act(async () => {
renderer?.update(createElement(Probe, { client: secondClient }))
})
expect(gates).toMatchObject({ hostCapabilities: [], statusPending: true })
} finally {
restore()
renderer?.unmount()
}
})
})
+24 -29
View File
@@ -11,7 +11,8 @@ export type HostStatusGates = {
statusPending: boolean
}
type LoadedHostStatusGates = HostStatusGates & {
// statusPending is not stored: pending-ness belongs to the live connection, not to the answer.
type LoadedHostStatusGates = Omit<HostStatusGates, 'statusPending'> & {
hostId: string | undefined
client: RpcClient
}
@@ -27,15 +28,21 @@ export function useHostStatusGates(args: {
}): HostStatusGates {
const { hostId, client, connState } = args
const [loaded, setLoaded] = useState<LoadedHostStatusGates | null>(null)
// Why (F10): a drop must not erase proven capabilities, but it does invalidate them — this keeps
// statusPending true across the reconnect refetch, so gates stay "unknown" while the data survives.
const [unverified, setUnverified] = useState(false)
useEffect(() => {
if (connState !== 'connected' || !client) {
// Why: reconnecting the same host/client must revalidate gates instead of reviving its prior status response.
setLoaded(null)
setUnverified(true)
return
}
let cancelled = false
const requestClient = client
const settle = (gates: Omit<HostStatusGates, 'statusPending'>) => {
setLoaded({ hostId, client: requestClient, ...gates })
setUnverified(false)
}
void (async () => {
try {
const response = await requestClient.sendRequest('status.get')
@@ -43,13 +50,10 @@ export function useHostStatusGates(args: {
return
}
if (!response.ok) {
setLoaded({
hostId,
client: requestClient,
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
compatVerdict: { kind: 'ok' },
statusPending: false
compatVerdict: { kind: 'ok' }
})
return
}
@@ -60,13 +64,10 @@ export function useHostStatusGates(args: {
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
})
setLoaded({
hostId,
client: requestClient,
settle({
hostCapabilities: status.capabilities ?? [],
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
compatVerdict: verdict,
statusPending: false
compatVerdict: verdict
})
if (verdict.kind === 'blocked') {
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
@@ -80,13 +81,10 @@ export function useHostStatusGates(args: {
} catch {
// Why: a transient status failure must not trap navigation; conservative feature gates remain disabled.
if (!cancelled) {
setLoaded({
hostId,
client: requestClient,
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
compatVerdict: { kind: 'ok' },
statusPending: false
compatVerdict: { kind: 'ok' }
})
}
}
@@ -97,13 +95,8 @@ export function useHostStatusGates(args: {
}, [client, connState, hostId])
// Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse.
if (
connState !== 'connected' ||
!client ||
!loaded ||
loaded.hostId !== hostId ||
loaded.client !== client
) {
const proven = loaded && loaded.hostId === hostId && loaded.client === client ? loaded : null
if (!proven) {
return {
hostCapabilities: EMPTY_HOST_CAPABILITIES,
floatingWorkspaceEnabled: false,
@@ -112,9 +105,11 @@ export function useHostStatusGates(args: {
}
}
return {
hostCapabilities: loaded.hostCapabilities,
floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled,
compatVerdict: loaded.compatVerdict,
statusPending: false
hostCapabilities: proven.hostCapabilities,
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
compatVerdict: proven.compatVerdict,
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
// longer blanks the capabilities this same host already proved.
statusPending: connState === 'connected' && unverified
}
}
@@ -0,0 +1,53 @@
import type { RpcClient } from './rpc-client'
import type { ConnectionState } from './types'
// Why: a replacement session publishes these to nobody until migrateTo binds it, so a
// suspended client shows grey for the whole dial (S2). 'connected' stays excluded — the
// migration tail owns it, and forwarding it early would advertise a session that still
// rejects requests.
const DIAL_PHASES: ConnectionState[] = ['connecting', 'handshaking', 'reconnecting']
export type MigrationDialStateForwarder = {
forwarded: () => boolean
stop: () => void
}
// Why: start only from an idle logical client and abandon the moment anything else
// publishes 'connected' — make-before-break replacement dials (lease rotation, the
// supervisor's suspectActive recovery) must never blink the dot on their way through.
// 'reconnecting' is deliberately not a start state: the previous session's retry loop
// is still bound and cycling, so two publishers would fight over a dot that is already
// amber. That case is served by the pending path label instead, not by forwarding.
function canForward(started: boolean, state: ConnectionState, suspended: boolean): boolean {
if (suspended) {
return true
}
return started ? state !== 'connected' : state === 'disconnected'
}
export function forwardMigrationDialState(args: {
session: RpcClient
snapshot: () => { state: ConnectionState; suspended: boolean }
publish: (state: ConnectionState) => void
}): MigrationDialStateForwarder {
let started = false
let stopped = false
const unsubscribe = args.session.onStateChange((next) => {
if (stopped || !DIAL_PHASES.includes(next)) {
return
}
const { state, suspended } = args.snapshot()
if (!canForward(started, state, suspended)) {
return
}
started = true
args.publish(next)
})
return {
forwarded: () => started,
stop: () => {
stopped = true
unsubscribe()
}
}
}
@@ -1,5 +1,5 @@
import * as ExpoCrypto from 'expo-crypto'
import type { ConnectionLogSink, HostProfile } from './types'
import type { ConnectionLogSink, ForegroundNudgeReason, HostProfile } from './types'
import { connect } from './rpc-client'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
@@ -25,6 +25,7 @@ import { RpcApplicationResponsiveness } from './rpc-application-responsiveness'
type EndpointLifecycle = {
setForeground(foreground: boolean): void
nudge(reason: ForegroundNudgeReason): void
stop(): void
}
@@ -121,6 +122,14 @@ export function startMobileEndpointLifecycle(
foreground = next
owner.setForeground(next)
},
nudge(reason) {
// Why: a focus nudge can precede the AppState listener; keep the closure in
// sync or a later supervisor swap would start with a stale background flag.
if (reason !== 'network-change') {
foreground = true
}
owner.nudge(reason)
},
stop() {
stopped = true
owner.stop()
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router'
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
function routerFixture() {
let foreground = false
const logical = {
getActivePath: vi.fn(() => 'relay'),
getState: vi.fn(() => 'connected'),
getGeneration: vi.fn(() => 1),
sendRequest: vi.fn(async () => ({}))
} as unknown as StableLogicalRpcClient
const handleActiveNudge = vi.fn(() => 'probe' as const)
const setForeground = vi.fn((next: boolean) => {
foreground = next
})
const scheduleDirectProbe = vi.fn()
const router = new MobileEndpointNudgeRouter({
logical,
controller: { handleActiveNudge } as unknown as RelayReconnectController,
now: () => 20_000,
isStopped: () => false,
isForeground: () => foreground,
setForeground,
replaceRelay: vi.fn(),
recoverAfterDeadProbe: vi.fn(),
scheduleDirectProbe
})
return { handleActiveNudge, logical, router, scheduleDirectProbe, setForeground }
}
describe('MobileEndpointNudgeRouter', () => {
it('processes a focus nudge after restoring lagging foreground state', () => {
const fixture = routerFixture()
fixture.router.nudge('focus')
expect(fixture.setForeground).toHaveBeenCalledWith(true)
expect(fixture.handleActiveNudge).toHaveBeenCalledWith(fixture.logical, 'focus')
expect(fixture.logical.sendRequest).toHaveBeenCalledWith('status.get', null, {
timeoutMs: 4000
})
expect(fixture.scheduleDirectProbe).toHaveBeenCalledOnce()
})
it('ignores a network nudge while backgrounded', () => {
const fixture = routerFixture()
fixture.router.nudge('network-change')
expect(fixture.setForeground).not.toHaveBeenCalled()
expect(fixture.handleActiveNudge).not.toHaveBeenCalled()
expect(fixture.scheduleDirectProbe).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,53 @@
import { MobileRelayFocusProbe } from './mobile-relay-focus-probe'
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ForegroundNudgeReason } from './types'
// Routes attention/network nudges: focus and app-resume probe a healthy relay,
// a network change replaces it make-before-break, everything else re-enters recovery.
export class MobileEndpointNudgeRouter {
private readonly focusProbe: MobileRelayFocusProbe
constructor(
private readonly args: {
logical: StableLogicalRpcClient
controller: RelayReconnectController
now: () => number
isStopped: () => boolean
isForeground: () => boolean
setForeground: (foreground: boolean) => void
replaceRelay: () => void
recoverAfterDeadProbe: (detail: string) => void
scheduleDirectProbe: () => void
}
) {
this.focusProbe = new MobileRelayFocusProbe({
logical: args.logical,
now: args.now,
canProbe: () => !args.isStopped() && args.isForeground(),
onDead: args.recoverAfterDeadProbe
})
}
nudge(reason: ForegroundNudgeReason): void {
const { args } = this
if (args.isStopped()) {
return
}
if (!args.isForeground()) {
// Why: a background network flap must not re-open a billed relay splice;
// focus/app-resume imply the app is visible even if AppState lags.
if (reason === 'network-change') {
return
}
args.setForeground(true)
}
const verdict = args.controller.handleActiveNudge(args.logical, reason)
if (verdict === 'probe') {
void this.focusProbe.probe()
} else if (verdict === 'replace') {
args.replaceRelay()
}
args.scheduleDirectProbe()
}
}
@@ -0,0 +1,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import {
bundle,
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher'
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
// Focus/network nudge routing, make-before-break replacement, and the
// happy-eyeballs race — split from the main supervisor suite (max-lines).
describe('mobile endpoint supervisor nudges', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('replaces a relay make-before-break on a network nudge without going grey', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected'))
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
// The OS reports a network handoff, but the relay never published onclose.
// The replacement authenticates, migrateTo swaps sessions — never disconnected.
supervisor.nudge('network-change')
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).toHaveBeenCalledTimes(2)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('suspends only after a failed replacement dial, then backs off further nudges', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('connected'))
.mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
// Keep direct unavailable so relay recovery stays the only path under test.
openDirect: vi.fn(() => new FakeSession('disconnected')),
// Deterministic full jitter: fraction 0.5 → half the backoff window.
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(openRelay).toHaveBeenCalledOnce()
// PEER_DROPPED on the replacement: the suspect session comes down so the
// armed retry can run, and the failure books the shared cooldown.
supervisor.nudge('network-change')
await vi.advanceTimersByTimeAsync(0)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(openRelay).toHaveBeenCalledTimes(2)
// More flap nudges share the existing cooldown rather than opening sockets.
for (let i = 0; i < 5; i++) {
supervisor.nudge('network-change')
await vi.advanceTimersByTimeAsync(0)
}
expect(openRelay).toHaveBeenCalledTimes(2)
// Exactly one retry fires at the 250 ms deterministic backoff boundary.
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(3)
supervisor.stop()
})
it('probes a healthy relay on a focus nudge and keeps it untouched', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.nudge('focus')
await vi.advanceTimersByTimeAsync(0)
expect(logical.sendRequest).toHaveBeenCalledWith('status.get', null, { timeoutMs: 4000 })
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(deps.openRelay).not.toHaveBeenCalled()
// Repeated focus events inside the probe window coalesce into one probe.
supervisor.nudge('focus')
await vi.advanceTimersByTimeAsync(0)
expect(logical.sendRequest).toHaveBeenCalledOnce()
supervisor.stop()
})
it('suspends and re-dials when the focus probe fails', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.sendRequest.mockRejectedValueOnce(new Error('relay RPC timed out: status.get'))
supervisor.nudge('focus')
await vi.advanceTimersByTimeAsync(0)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
await vi.waitFor(() => expect(deps.openRelay).toHaveBeenCalledOnce())
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('queues a network replacement that lands while another dial owns the mutex', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
let resolveWrite: (() => void) | null = null
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
writeBundle: vi
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveWrite = resolve
})
)
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(deps.openRelay).toHaveBeenCalledOnce()
// First handoff nudge dials a replacement whose bookkeeping write is slow,
// holding the recovery mutex; the second nudge arriving then must neither
// suspend the session nor be dropped.
supervisor.nudge('network-change')
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).toHaveBeenCalledTimes(2)
supervisor.nudge('network-change')
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
resolveWrite?.()
await vi.advanceTimersByTimeAsync(0)
// The in-flight replacement satisfies the queued intent — no third socket.
expect(deps.openRelay).toHaveBeenCalledTimes(2)
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('keeps a healthy relay bound when a nudge finds no dialable credential', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const expired = {
...bundle,
current: { ...bundle.current, expiresAt: 1 }
}
const deps = dependencies({
readBundle: vi.fn(async () => expired),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.nudge('network-change')
await vi.advanceTimersByTimeAsync(0)
// Why: no dial happened, so nothing has disproven the live session.
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('withdraws the racing relay dial when direct authenticated while it was in flight', async () => {
// Direct won between the grace timer firing and the relay session
// authenticating; the migration must withdraw instead of closing the winner.
const logical = new FakeLogicalClient('connected', 'lan')
const relaySession = new FakeRelaySession('connected')
const setActiveSession = vi.fn()
const establisher = new MobileRelaySessionEstablisher({
logical,
controller: { setActiveSession } as unknown as RelayReconnectController,
openRelay: vi.fn(() => relaySession),
randomBytes: (length) => new Uint8Array(length),
writeBundle: vi.fn(async () => {}),
isActive: () => true,
isForeground: () => true,
relay: () => host.relay,
resolveRelay: vi.fn(async ({ relay: endpoint }) => endpoint),
persistResolvedRelay: vi.fn(async () => {}),
bundle: () => bundle,
adoptBundle: vi.fn(),
recordMigration: vi.fn(),
scheduleLease: vi.fn(),
scheduleDirectProbe: vi.fn(),
onBookkeepingError: vi.fn(),
onDialFailure: vi.fn()
})
const outcome = await establisher.dialEligible([bundle.current])
expect(outcome).toEqual({ outcome: 'aborted' })
expect(setActiveSession).not.toHaveBeenCalled()
expect(relaySession.close).toHaveBeenCalled()
expect(logical.getActivePath()).toBe('lan')
})
})
@@ -1,8 +1,62 @@
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
// Why: a suspect session that survived a failed replacement dial must come down,
// else the armed unforced retry dead-ends on needsRecovery seeing stale 'connected'.
export function suspendRelayIfStillConnected(
controller: RelayReconnectController,
logical: StableLogicalRpcClient
): void {
if (logical.getState() === 'connected') {
controller.suspendActiveRelay(logical)
}
}
type RelayDialResult = { ok: true } | { ok: false; error: Error }
// Why: a locally-aborted dial (background/stop/missing state) proves nothing about the
// cell assignment, so the director fallback must not burn a resolution round on it.
export class RelayDialAbortedError extends Error {
constructor() {
super('relay dial aborted before opening a session')
}
}
// One credential attempt: dial, and on a director-class failure re-resolve the
// cell assignment, persist it durably, then dial once more.
export async function dialRelayThroughDirectorFallback(args: {
resumeToken: string
relay: () => HostProfile['relay']
dial: () => Promise<RelayDialResult>
resolveRelay: (input: {
relay: NonNullable<HostProfile['relay']>
resumeToken: string
}) => Promise<MobileRelayEndpoint>
persistResolvedRelay: (resolved: MobileRelayEndpoint) => Promise<void>
}): Promise<RelayDialResult> {
const first = await args.dial()
const relay = args.relay()
if (
first.ok ||
first.error instanceof RelayDialAbortedError ||
!isDirectorResolutionFailure(first.error) ||
!relay
) {
return first
}
try {
const resolved = await args.resolveRelay({ relay, resumeToken: args.resumeToken })
await args.persistResolvedRelay(resolved)
return await args.dial()
} catch (error) {
return { ok: false, error: toError(error) }
}
}
export function isDirectorResolutionFailure(error: Error): boolean {
return (
!(error instanceof MobileE2EEAuthenticationError) &&
@@ -72,17 +72,31 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
this.path = path
}
migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
migrateTo = vi.fn(
async (
session: RpcClient,
path: MobileConnectionPath,
_timeoutMs?: number,
shouldAbort?: () => boolean
) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
// Mirrors the real client: a racing caller withdraws after auth, before the swap.
if (shouldAbort?.()) {
session.close()
throw new Error('migration superseded')
}
this.path = path
this.generation += 1
this.publishState('connected')
}
this.path = path
this.generation += 1
this.publishState('connected')
})
)
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
// This fake migrates instantly, so no dial is ever in flight to name.
getPendingPath = () => null
getGeneration = () => this.generation
}
@@ -35,7 +35,12 @@ describe('mobile endpoint supervisor', () => {
await supervisor.start()
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.migrateTo).toHaveBeenCalledWith(
expect.any(FakeRelaySession),
'relay',
undefined,
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
expect(deps.writeBundle).toHaveBeenCalledWith(
expect.objectContaining({ current: expect.objectContaining({ version: 2 }) })
@@ -60,7 +65,12 @@ describe('mobile endpoint supervisor', () => {
logical.publishState('reconnecting')
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.migrateTo).toHaveBeenCalledWith(
expect.any(FakeRelaySession),
'relay',
undefined,
expect.any(Function)
)
supervisor.stop()
})
@@ -71,7 +81,12 @@ describe('mobile endpoint supervisor', () => {
await supervisor.start()
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.migrateTo).toHaveBeenCalledWith(
expect.any(FakeRelaySession),
'relay',
undefined,
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
@@ -162,46 +177,6 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('replaces a half-open relay on a network nudge, then backs off failed resumes', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('connected'))
.mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
// Keep direct unavailable so relay recovery stays the only path under test.
openDirect: vi.fn(() => new FakeSession('disconnected')),
// Deterministic full jitter: fraction 0.5 → half the backoff window.
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(openRelay).toHaveBeenCalledOnce()
// The OS reports a network handoff, but the dead relay never published onclose.
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(openRelay).toHaveBeenCalledTimes(2)
// The relay cell rejects the replacement with PEER_DROPPED; more flap nudges
// must share the existing cooldown rather than opening more sockets.
for (let i = 0; i < 5; i++) {
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
}
expect(openRelay).toHaveBeenCalledTimes(2)
// Exactly one retry fires at the 250 ms deterministic backoff boundary.
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(3)
supervisor.stop()
})
it('backs off a close from the active relay before opening its replacement', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
@@ -720,7 +695,10 @@ describe('mobile endpoint supervisor', () => {
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledTimes(2)
supervisor.setForeground(true)
// A network nudge inside the cooldown must not dial early and must not tear
// down the healthy session; the queued intent runs at the 250 ms boundary.
supervisor.nudge('network-change')
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
@@ -774,6 +752,91 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('races a relay dial when the direct dial stalls unauthenticated', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(2_499)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connecting')
// The direct dial never authenticates; the relay wins the race through migrateTo.
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
expect(logical.migrateTo).toHaveBeenCalledWith(
expect.any(FakeRelaySession),
'relay',
undefined,
expect.any(Function)
)
supervisor.stop()
})
it('cancels the grace race when the direct dial authenticates first', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('connected')
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(5_000)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getActivePath()).toBe('lan')
supervisor.stop()
})
it('never races a relay dial against a desktop with no relay endpoint', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, { ...host, relay: undefined }, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(5_000)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
supervisor.stop()
})
it('drops the pending grace race when the phone backgrounds', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(5_000)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
supervisor.stop()
})
it('books the shared cooldown when the grace race loses its dial', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({ openRelay, randomBytes: () => new Uint8Array([128, 0]) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(2_500)
expect(openRelay).toHaveBeenCalledOnce()
// The armed retry runs unforced, so it yields to the still-progressing direct
// dial: the race gets one attempt, never a socket-per-cooldown loop.
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledOnce()
// Direct finally gives up: ordinary recovery still owns the failure.
logical.publishState('reconnecting')
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
supervisor.stop()
})
it('releases a background relay session and reconnects it on foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
+150 -161
View File
@@ -4,21 +4,18 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller'
import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import {
encodeBase64Url,
isDirectorResolutionFailure,
persistRelayHost,
toError
suspendRelayIfStillConnected
} from './mobile-endpoint-supervisor-support'
import { selectDialableRelayCredentials } from './mobile-relay-credential-selection'
import { createRelayRecoveryLog, type RelayRecoveryLog } from './mobile-relay-recovery-log'
import {
applyResumeConfirmation,
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { MobileRelayCredentialRefresh } from './mobile-relay-credential-refresh'
import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router'
import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer'
import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
import type { ForegroundNudgeReason, HostProfile } from './types'
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
@@ -31,7 +28,8 @@ export class MobileEndpointSupervisor {
private stopped = false
private foreground = true
private operationInFlight = false
private credentialRotationInFlight = false
private pendingReplace = false
private readonly nudgeRouter: MobileEndpointNudgeRouter
private relayRotationPending = false
private unsubscribeState: (() => void) | null = null
private readonly hysteresis: MobileEndpointHysteresis
@@ -39,6 +37,9 @@ export class MobileEndpointSupervisor {
private readonly leaseRotation: RelayLeaseRotationTimer
private readonly logRelay: RelayRecoveryLog
private readonly directProbe: DirectReturnProbe
private readonly directGrace: MobileRelayDirectGraceTimer
private readonly sessionEstablisher: MobileRelaySessionEstablisher
private readonly credentialRefresh: MobileRelayCredentialRefresh
constructor(
private readonly logical: StableLogicalRpcClient,
@@ -53,10 +54,84 @@ export class MobileEndpointSupervisor {
})
this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog)
this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this))
this.credentialRefresh = new MobileRelayCredentialRefresh({
logical,
controller: this.relayReconnect,
dependencies,
bundle: () => this.bundle,
host: () => this.host,
adoptBundle: (bundle) => {
this.bundle = bundle
},
adoptHost: (host) => {
this.host = host
this.dependencies.onHostUpdated?.(host)
},
isStopped: () => this.stopped,
isForeground: () => this.foreground,
recoverRelay: () => void this.recoverRelay()
})
this.nudgeRouter = new MobileEndpointNudgeRouter({
logical,
controller: this.relayReconnect,
now: dependencies.now,
isStopped: () => this.stopped,
isForeground: () => this.foreground,
setForeground: (foreground) => this.setForeground(foreground),
replaceRelay: () => void this.recoverRelay(true, true),
recoverAfterDeadProbe: (detail) => {
this.logRelay('relay probe failed; recovering', detail)
suspendRelayIfStillConnected(this.relayReconnect, this.logical)
void this.recoverRelay()
},
scheduleDirectProbe: () => this.directProbe.schedule(0)
})
this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => {
this.relayRotationPending = true
void this.recoverRelay(true)
})
// Why: the race owns recovery exactly like a network-change replacement — its
// failure must book the shared cooldown. recoverRelay's own guards already
// cover stopped/background/no-relay, so the timer needs no scope check.
this.directGrace = new MobileRelayDirectGraceTimer(dependencies, logical, () => {
void this.recoverRelay(true, true)
})
this.sessionEstablisher = new MobileRelaySessionEstablisher({
logical,
controller: this.relayReconnect,
openRelay: dependencies.openRelay,
randomBytes: dependencies.randomBytes,
writeBundle: dependencies.writeBundle,
isActive: () => !this.stopped && this.foreground,
isForeground: () => this.foreground,
relay: () => this.host.relay,
resolveRelay: dependencies.resolveRelay,
persistResolvedRelay: async (resolved) => {
if (this.stopped) {
return
}
this.host = await persistRelayHost(this.host, resolved, dependencies.saveHost)
if (!this.stopped) {
this.dependencies.onHostUpdated?.(this.host)
}
},
bundle: () => this.bundle,
adoptBundle: (bundle) => {
this.bundle = bundle
},
recordMigration: () => {
this.relayRotationPending = false
this.hysteresis.recordMigration(dependencies.now())
this.logRelay('runtime channel migrated to relay')
},
scheduleLease: (expiry) =>
this.leaseRotation.scheduleFromLease(this.stopped || !this.foreground ? null : expiry),
scheduleDirectProbe: () => this.directProbe.schedule(),
onBookkeepingError: (error) =>
this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)),
onDialFailure: (error) =>
this.logRelay('relay dial failed', `${error.name}: ${String(error.message).slice(0, 80)}`)
})
this.directProbe = new DirectReturnProbe(dependencies, {
hysteresis: this.hysteresis,
host: () => this.host,
@@ -70,11 +145,15 @@ export class MobileEndpointSupervisor {
onDirectMigrated: async () => {
this.leaseRotation.clear()
this.relayRotationPending = false
await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
await this.credentialRefresh.run(this.relayReconnect.resetForDirectConnection())
},
afterProbe: () => {
this.operationInFlight = false
if (this.relayRotationPending || this.logical.getState() !== 'connected') {
if (
this.pendingReplace ||
this.relayRotationPending ||
this.logical.getState() !== 'connected'
) {
void this.recoverRelay(this.relayRotationPending)
}
}
@@ -93,8 +172,9 @@ export class MobileEndpointSupervisor {
}
this.unsubscribeState = this.logical.onStateChange((state) => {
if (state === 'connected') {
this.directGrace.clear()
if (this.logical.getActivePath() !== 'relay') {
void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
void this.credentialRefresh.run(this.relayReconnect.resetForDirectConnection())
}
this.directProbe.schedule()
} else {
@@ -109,6 +189,7 @@ export class MobileEndpointSupervisor {
await this.recoverRelay()
} else {
this.directProbe.schedule()
this.directGrace.arm()
}
}
@@ -118,15 +199,21 @@ export class MobileEndpointSupervisor {
if (foreground) {
this.relayReconnect.handleForeground(this.logical, wasForeground)
this.directProbe.schedule(0)
this.directGrace.arm()
} else {
// Why: background phones must not hold billed relay data splices.
this.relayReconnect.suspendActiveRelay(this.logical)
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
}
}
nudge(reason: ForegroundNudgeReason): void {
this.nudgeRouter.nudge(reason)
}
stop(): void {
this.stopped = true
this.unsubscribeState?.()
@@ -134,22 +221,42 @@ export class MobileEndpointSupervisor {
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
}
private async recoverRelay(forceReplacement = false): Promise<void> {
// Why: connecting/handshaking is live direct progress; a relay dial would race it.
if (
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.host.relay ||
(!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState()))
) {
// forceReplacement: dial past the "direct still looks live" guard — a lease
// rotation, a network-change replacement, or the happy-eyeballs grace race.
// ownsRecovery: this dial is the connection's only hope, so a failure books the
// shared cooldown and any session left stale-'connected' by a half-open socket
// comes down; lease rotation clears it because armRetry owns its own retry.
private async recoverRelay(forceReplacement = false, ownsRecovery = false): Promise<void> {
if (this.stopped || !this.foreground || !this.host.relay) {
return
}
if (this.operationInFlight) {
// Why: a 12s direct probe can own the mutex when a network handoff lands;
// afterProbe replays the queued replacement so the signal is never lost.
this.pendingReplace ||= forceReplacement && ownsRecovery
return
}
if (this.pendingReplace) {
this.pendingReplace = false
forceReplacement = true
ownsRecovery = true
}
// Why: connecting/handshaking is live direct progress; an unforced relay dial
// would race it before the grace timer has given direct its head start.
if (!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState())) {
return
}
// Why: revival and lease timers can overlap resume failures; one shared cooldown
// prevents PEER_DROPPED/LIMIT_EXCEEDED reconnect churn.
if (this.relayReconnect.shouldDefer()) {
if (ownsRecovery) {
// Why: never tear down a session no dial has disproven — the intent stays
// queued so the armed retry runs forced once the cooldown lapses.
this.pendingReplace = true
}
this.logRelay('recovery deferred by cooldown or gate')
return
}
@@ -173,30 +280,33 @@ export class MobileEndpointSupervisor {
: 'no relay credential bundle; slow reprobe armed'
)
this.relayReconnect.armCredentialReprobe()
if (ownsRecovery) {
// Why: no dial happened — keep the session and the intent; the reprobe
// runs forced and replaces make-before-break once a credential exists.
this.pendingReplace = true
}
return
}
for (const credential of selection.credentials) {
const result = await this.tryRelayCredential(credential)
if (result.ok) {
retryAfterOperation = this.logical.getState() !== 'connected'
return
}
lastError = result.error
this.logRelay(
'relay dial failed',
`${result.error.name}: ${String(result.error.message).slice(0, 80)}`
)
if (this.relayReconnect.shouldTryGraceAfterRelayFailure(result.error)) {
// Why: a rejected version stays invalid; retry only the grace credential.
this.relayReconnect.recordRejectedCredential(credential.version)
} else {
break
}
const dialed = await this.sessionEstablisher.dialEligible(selection.credentials)
if (dialed.outcome === 'established') {
// Why: a fresh socket satisfies any replacement intent queued mid-dial.
this.pendingReplace = false
retryAfterOperation = this.logical.getState() !== 'connected'
return
}
if (dialed.outcome === 'aborted') {
// Why: direct won the race or the supervisor went inactive — not a
// failure; booking backoff would delay the next genuine recovery.
return
}
lastError = dialed.error
// Why: cleanup may happen while a relay dial is awaiting the network;
// record its outcome without recreating a foreground retry timer.
const scheduleRetry = !forceReplacement && this.foreground && !this.stopped
const scheduleRetry = (!forceReplacement || ownsRecovery) && this.foreground && !this.stopped
this.relayReconnect.registerFailure(lastError, scheduleRetry)
if (ownsRecovery) {
suspendRelayIfStillConnected(this.relayReconnect, this.logical)
}
} finally {
this.operationInFlight = false
if (forceReplacement && this.relayRotationPending && !this.stopped && this.foreground) {
@@ -208,125 +318,4 @@ export class MobileEndpointSupervisor {
}
}
}
private async tryRelayCredential(credential: {
token: string
version: number
}): Promise<{ ok: true } | { ok: false; error: Error }> {
const first = await this.openAndMigrateRelay(credential)
if (first.ok) {
return first
}
if (!isDirectorResolutionFailure(first.error) || !this.host.relay) {
return first
}
try {
const resolved = await this.dependencies.resolveRelay({
relay: this.host.relay,
resumeToken: credential.token
})
if (this.stopped) {
return { ok: false, error: new Error('relay state missing') }
}
this.host = await persistRelayHost(this.host, resolved, this.dependencies.saveHost)
this.dependencies.onHostUpdated?.(this.host)
return await this.openAndMigrateRelay(credential)
} catch (error) {
return { ok: false, error: toError(error) }
}
}
private async openAndMigrateRelay(credential: {
token: string
version: number
}): Promise<{ ok: true } | { ok: false; error: Error }> {
// Why: director resolution and grace fallback can finish after background/stop.
if (this.stopped || !this.foreground || !this.host.relay || !this.bundle) {
return { ok: false, error: new Error('relay state missing') }
}
const session = this.dependencies.openRelay(
this.host.relay,
credential,
`confirm-${encodeBase64Url(this.dependencies.randomBytes(16))}`
)
try {
await this.logical.migrateTo(session, 'relay')
this.relayReconnect.setActiveSession(session)
if (!this.foreground) {
this.relayReconnect.suspendActiveRelay(this.logical)
}
this.relayRotationPending = false
this.hysteresis.recordMigration(this.dependencies.now())
this.logRelay('runtime channel migrated to relay')
const confirmation = session.getResumeConfirmation()
if (confirmation) {
this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation)
// Why: the relay is already authenticated; a SecureStore failure must
// not open another socket or count against transport recovery backoff.
await this.dependencies.writeBundle(this.bundle).catch(() => {})
}
// Why: async persistence can finish after stop/background; never recreate a stale timer.
// Why: rotate against the resume credential's expiry, never the hello's
// leaseExpiresAt — that field is the cell's ~10s attach-reservation
// deadline, and using it forced a session replacement every second.
// Why: renewed=false means a re-resume provably returns the same
// unchanged deadline — rotating then just churns one replacement per
// clamp floor until a fresh credential arrives over direct or disk.
this.leaseRotation.scheduleFromLease(
this.stopped || !this.foreground || confirmation?.renewed === false
? null
: (confirmation?.resumeExpiresAt ?? session.getResumeExpiresAt())
)
this.directProbe.schedule()
return { ok: true }
} catch (error) {
return { ok: false, error: session.getFailure() ?? toError(error) }
}
}
private async rotateCredentialIfNeeded(force = false): Promise<void> {
if (
this.stopped ||
this.credentialRotationInFlight ||
!this.bundle ||
this.logical.getActivePath() === 'relay' ||
(!force && !mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now()))
) {
return
}
this.credentialRotationInFlight = true
let credentialRefreshed = false
try {
const result = await rotateMobileRelayCredential({
client: this.logical,
bundle: this.bundle,
writeBundle: this.dependencies.writeBundle,
randomBytes: this.dependencies.randomBytes
})
if (this.stopped) {
return
}
this.bundle = result.bundle
// Why: a scheduled rotation can finish after the old credential enters the rejection gate.
credentialRefreshed = true
this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost)
this.dependencies.onHostUpdated?.(this.host)
} catch {
// Why: pending material remains durable; the next authenticated direct
// opportunity must reconcile it before creating another install key.
} finally {
if (credentialRefreshed) {
this.relayReconnect.completeCredentialRefresh()
}
this.credentialRotationInFlight = false
if (
credentialRefreshed &&
!this.stopped &&
this.foreground &&
this.relayReconnect.needsRecovery(this.logical.getState())
) {
void this.recoverRelay()
}
}
}
}
@@ -0,0 +1,75 @@
import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
import { persistRelayHost } from './mobile-endpoint-supervisor-support'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import {
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
export class MobileRelayCredentialRefresh {
private inFlight = false
constructor(
private readonly args: {
logical: StableLogicalRpcClient
controller: RelayReconnectController
dependencies: MobileEndpointSupervisorDependencies
bundle: () => MobileRelayCredentialBundle | null
host: () => HostProfile
adoptBundle: (bundle: MobileRelayCredentialBundle) => void
adoptHost: (host: HostProfile) => void
isStopped: () => boolean
isForeground: () => boolean
recoverRelay: () => void
}
) {}
async run(force = false): Promise<void> {
const { args } = this
const bundle = args.bundle()
if (
args.isStopped() ||
this.inFlight ||
!bundle ||
args.logical.getActivePath() === 'relay' ||
(!force && !mobileRelayCredentialNeedsRotation(bundle, args.dependencies.now()))
) {
return
}
this.inFlight = true
let refreshed = false
try {
const result = await rotateMobileRelayCredential({
client: args.logical,
bundle,
writeBundle: args.dependencies.writeBundle,
randomBytes: args.dependencies.randomBytes
})
if (args.isStopped()) {
return
}
args.adoptBundle(result.bundle)
refreshed = true
const host = await persistRelayHost(args.host(), result.relay, args.dependencies.saveHost)
args.adoptHost(host)
} catch {
// Pending material remains durable for the next authenticated direct opportunity.
} finally {
if (refreshed) {
args.controller.completeCredentialRefresh()
}
this.inFlight = false
if (
refreshed &&
!args.isStopped() &&
args.isForeground() &&
args.controller.needsRecovery(args.logical.getState())
) {
args.recoverRelay()
}
}
}
}
@@ -130,6 +130,38 @@ export function applyResumeConfirmation(
return bundle
}
// Applies a migrated session's resume confirmation to the durable bundle and
// derives the lease expiry to rotate against.
// Why: rotate against the resume credential's expiry, never the hello's
// leaseExpiresAt — that field is the cell's ~10s attach-reservation deadline,
// and using it forced a session replacement every second.
// Why: renewed=false means a re-resume provably returns the same unchanged
// deadline — rotating then just churns one replacement per clamp floor until a
// fresh credential arrives over direct or disk.
export async function persistResumeConfirmation(args: {
session: {
getResumeConfirmation(): DeviceResumeConfirmed | null
getResumeExpiresAt(): number | null
}
bundle: MobileRelayCredentialBundle
usedCredentialVersion: number
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
}): Promise<{ bundle: MobileRelayCredentialBundle; leaseExpiry: number | null }> {
const confirmation = args.session.getResumeConfirmation()
let bundle = args.bundle
if (confirmation) {
bundle = applyResumeConfirmation(bundle, args.usedCredentialVersion, confirmation)
// Why: the relay is already authenticated; a SecureStore failure must not
// open another socket or count against transport recovery backoff.
await args.writeBundle(bundle).catch(() => {})
}
const leaseExpiry =
confirmation?.renewed === false
? null
: (confirmation?.resumeExpiresAt ?? args.session.getResumeExpiresAt())
return { bundle, leaseExpiry }
}
async function getEndpoints(client: RpcClient, installReqId: string) {
const response = await client.sendRequest('pairing.getEndpoints', { installReqId })
if (!response.ok) {
@@ -0,0 +1,48 @@
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
// Why: on a black-holed LAN endpoint the direct dial sits in 'connecting' for the
// whole 12s connect timeout (rpc-client CONNECT_TIMEOUT_MS), and relay recovery
// cannot even start meanwhile because connecting/handshaking count as live direct
// progress. Happy eyeballs: give direct this much of a head start, then race the
// relay dial — migrateTo hands the logical client to whichever authenticates first.
const DIRECT_DIAL_GRACE_MS = 2500
type DirectGraceTimerDependencies = {
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
// One-shot timer that releases the relay dial when the direct dial has not
// authenticated within the grace. The supervisor arms it at start and on
// foreground restore, and clears it on connect, background, and stop.
export class MobileRelayDirectGraceTimer {
private timer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly dependencies: DirectGraceTimerDependencies,
private readonly logical: StableLogicalRpcClient,
private readonly dialRelay: () => void
) {}
// No-op unless the direct dial is still unauthenticated, so a healthy LAN and
// an already-failed direct path (recovery owns that) never open a relay socket.
arm(): void {
const state = this.logical.getState()
if (this.timer || (state !== 'connecting' && state !== 'handshaking')) {
return
}
this.timer = this.dependencies.setTimer(() => {
this.timer = null
if (this.logical.getState() !== 'connected') {
this.dialRelay()
}
}, DIRECT_DIAL_GRACE_MS)
}
clear(): void {
if (this.timer) {
this.dependencies.clearTimer(this.timer)
this.timer = null
}
}
}
@@ -40,6 +40,12 @@ export class MobileRelayDirectUpgradeController {
}
}
// No relay session exists yet; the direct socket already self-probes on
// notifyForeground, so a nudge only retries the pending upgrade.
nudge(): void {
this.setForeground(true)
}
stop(): void {
this.stopped = true
this.unsubscribe?.()
@@ -0,0 +1,53 @@
import {
isLogicalClientCutoverError,
type StableLogicalRpcClient
} from './stable-logical-rpc-client'
// Why: a probe must fail well before the 12s connect ceiling so a dead socket
// still recovers in a few seconds, but outlast one slow cellular RTT.
const PROBE_TIMEOUT_MS = 4_000
// Why: focus fires per navigation event; one liveness answer covers them all.
const PROBE_MIN_INTERVAL_MS = 10_000
// Cheap liveness check for a focus nudge on an active relay: a healthy session
// answers and nothing visible changes; only a dead one is handed to onDead.
export class MobileRelayFocusProbe {
private lastProbeAt = 0
constructor(
private readonly args: {
logical: StableLogicalRpcClient
now: () => number
canProbe: () => boolean
onDead: (detail: string) => void
}
) {}
async probe(): Promise<void> {
const { logical, now } = this.args
const at = now()
if (
!this.args.canProbe() ||
at - this.lastProbeAt < PROBE_MIN_INTERVAL_MS ||
logical.getActivePath() !== 'relay' ||
logical.getState() !== 'connected'
) {
return
}
this.lastProbeAt = at
const generation = logical.getGeneration()
try {
await logical.sendRequest('status.get', null, { timeoutMs: PROBE_TIMEOUT_MS })
} catch (error) {
if (
isLogicalClientCutoverError(error) ||
!this.args.canProbe() ||
logical.getGeneration() !== generation ||
logical.getActivePath() !== 'relay'
) {
return
}
this.args.onDead(String((error as Error).message).slice(0, 80))
}
}
}
@@ -27,6 +27,7 @@ vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
}))
import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client'
import type { ConnectionLogEntry } from './types'
class FakeSocket {
readonly OPEN = 1
@@ -132,4 +133,58 @@ describe('mobile relay physical pairing client', () => {
await expect(status).rejects.toEqual(new RelayOuterError(4404))
expect(fakes.start).not.toHaveBeenCalled()
})
it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => {
const socket = new FakeSocket()
const entries: ConnectionLogEntry[] = []
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket,
onLog: (entry) => entries.push(entry)
})
socket.onopen?.()
socket.receive(
JSON.stringify({
type: 'relay-hello',
ok: true,
credentialKind: 'invite',
leaseExpiresAt: Date.now() + 60_000
})
)
await vi.waitFor(() => expect(fakes.start).toHaveBeenCalledOnce())
fakes.channelOptions!.onAuthenticated()
client.close()
expect(entries.map((entry) => `${entry.level}|${entry.message}|${entry.detail}`)).toEqual([
'info|Relay: dialing cell|relay-c1.onorca.dev',
'info|Relay: cell socket open|Sending relay credential',
'info|Relay: cell accepted credential|Starting E2EE handshake',
'success|Relay: authenticated|Channel ready for RPC',
'info|Relay: pairing socket closed|relay-c1.onorca.dev'
])
expect(JSON.stringify(entries)).not.toContain(relay.inviteToken)
})
it('logs the relay close code when the cell rejects the credential', async () => {
const socket = new FakeSocket()
const entries: ConnectionLogEntry[] = []
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket,
onLog: (entry) => entries.push(entry)
})
const status = client.sendRequest('status.get')
socket.receive(JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 }))
await expect(status).rejects.toEqual(new RelayOuterError(4404))
expect(entries.at(-1)).toMatchObject({
level: 'warn',
message: 'Relay: pairing socket closed',
detail: 'relay close code 4404'
})
})
})
@@ -2,9 +2,11 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offe
import { RelayPhoneHelloSchema } from '../../../src/shared/mobile-relay-phone-protocol'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log'
import { isRpcResponse } from './rpc-response-shape'
import { RecoverableRpcError } from './recoverable-rpc-error'
import type { RpcResponse } from './types'
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogSink, RpcResponse } from './types'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
export { RelayOuterError } from './mobile-relay-e2ee-link'
import { RelayOuterError } from './mobile-relay-e2ee-link'
@@ -28,9 +30,13 @@ export function connectMobileRelayForPairing(args: {
expectedCredentialKind?: 'invite' | 'resume'
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
onLog?: ConnectionLogSink
}): PairingCandidateClient {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
const socketUrl = relayPhoneWebSocketUrl(args.relay)
const log = createPairingRelayLogger(args.onLog)
const cellHost = redactSocketEndpoint(socketUrl)
log('info', 'Relay: dialing cell', cellHost)
const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(socketUrl)
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: args.desktopPublicKeyB64,
@@ -40,6 +46,7 @@ export function connectMobileRelayForPairing(args: {
const pending = new Map<string, PendingRequest>()
let requestCounter = 0
let closed = false
let intentionallyClosed = false
let outerReady = false
let authenticated = false
let resolveAuthenticated!: () => void
@@ -55,6 +62,7 @@ export function connectMobileRelayForPairing(args: {
decodeBinary: websocketPayloadToUint8,
onAuthenticated: () => {
authenticated = true
log('success', 'Relay: authenticated', 'Channel ready for RPC')
resolveAuthenticated()
},
onText: (plaintext) => {
@@ -79,6 +87,7 @@ export function connectMobileRelayForPairing(args: {
})
socket.onopen = () => {
log('info', 'Relay: cell socket open', 'Sending relay credential')
socket.send(
JSON.stringify({
type: 'relay-auth',
@@ -127,6 +136,7 @@ export function connectMobileRelayForPairing(args: {
throw new Error('relay credential resolved as an unexpected credential kind')
}
outerReady = true
log('info', 'Relay: cell accepted credential', 'Starting E2EE handshake')
channel.start()
}
@@ -135,6 +145,11 @@ export function connectMobileRelayForPairing(args: {
return
}
closed = true
if (intentionallyClosed) {
log('info', 'Relay: pairing socket closed', cellHost)
} else {
log('warn', 'Relay: pairing socket closed', pairingRelayErrorDetail(error))
}
channel.dispose()
rejectAuthenticated(error)
for (const request of pending.values()) {
@@ -167,7 +182,10 @@ export function connectMobileRelayForPairing(args: {
}
})
},
close: () => fail(new Error('relay pairing client closed'))
close: () => {
intentionallyClosed = true
fail(new Error('relay pairing client closed'))
}
}
}

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