* fix(mobile): keep healthy relays green through focus and network nudges (F1+F2) Focus/app-resume nudges probe the active relay instead of suspending it; network-change nudges replace it make-before-break, suspending only after a failed dial. Mount, Retry, and host-swap windows read 'connecting' instead of 'disconnected'; the host list keeps last-known worktrees for every not-connected state and spins instead of rendering nothing. * feat(mobile): surface the pairing relay path in the pairing log (F3) The relay candidate was silent during pairing: dialing, E2EE handshake, director recovery, and the winning path now emit redacted phase lines through the same connectOptions.onLog the direct path already used. * docs(mobile): relay UX investigation findings and F0-F10 fix plan * feat(mobile): name and narrate relay dials while they happen (F5) migrateTo forwards the dialing session's connecting/handshaking/reconnecting phases whenever the client is suspended or disconnected — never downgrading a live session — and exposes getPendingPath so the host card can say '· Orca Relay' during the dial instead of only after it. * feat(mobile): race a relay dial when the direct dial stalls (F6) A 2.5s grace timer starts relay recovery while an unauthenticated direct dial is still inside its 12s connect window; the race gets one attempt through the existing mutex/cooldown machinery, cancels when direct authenticates, and never arms for hosts without a relay endpoint. * fix(mobile): overlay the protocol gate instead of unmounting the host stack (F9) A pending status.get used to swap the mounted HostStack for a spinner at the moment the socket connected, destroying in-flight nested navigation. Once children have rendered for a host they stay mounted under an opaque touch-blocking overlay; first visits and blocked verdicts keep the old behavior. * fix(mobile): keep loaded data through transient connection blips (F10) Git history no longer blanks on reconnect (and commit files refetch instead of caching an offline empty answer), the repo picker keeps its last-good list when an in-flight repo.list rejects, the diff review's ready-state preservation actually runs, and proven host capabilities survive a drop flagged unverified instead of being wiped. * feat(mobile): coordinate every home deep push and bounce dead resume targets (F4+F7+F8) Notification taps, the Accounts card, and host-edit now use the shared mount-then-replace transition (with a focused-route walker so root-layout scope works); the Resume card renders from the snapshot in a disabled state so its late arrival can't shift Tasks under the thumb; resume targets are validated against proven catalog data, and a session route whose worktree the host proves missing bounces to the host index with a notice banner instead of stranding on a dead screen. * test(mobile): cover the resume-target and notice policies (F7) Key notice dismissal by code so closing one banner cannot swallow a later, different one, and move the visibility rule into host-route-notice.ts where it is testable without a screen. Adds the missing units for F7's decision points: isResumeTargetConfirmedMissing (unproven catalog is silence, synthetic routes exempt), the validating last-visited reader, and the notice visibility rule. * fix(mobile): review-pass hardening for the gate overlay and diff preservation Adversarial review findings: the reader's hunk position now survives a connection blip (reset only on item change), the covered stack is hidden from TalkBack while the gate overlay is up, and the overlay's hit-test comment is scoped honestly to in-tree views (native-Modal drawers present above it — follow-up). * fix(mobile): CI + CodeRabbit review fixes for #12609 Move the findings doc under docs/ (root directory guard), drop two unused eslint-disable directives, and address review findings: an unproven snapshot seed can no longer downgrade a proven worktree catalog; a locally-aborted relay dial skips the director fallback; post-migration bookkeeping failures log instead of masquerading as dial failures (which could suspend the healthy session); the auth wait arms its timeout before subscribing; forwarded dial phases stop at close(); the legacy selector_not_found fallback requires runtime_error; the diff-loading effect depends on the fields it reads; and host-edit auto-cancellation is now pinned by a test. * fix(mobile): second review round — queued replacements, race fence, confirmed bounces A network-change replacement now survives the recovery mutex and cooldowns as a queued intent instead of being dropped or suspending a healthy session — only a failed dial or a dead probe tears one down. The happy-eyeballs migration withdraws when direct authenticated during the relay dial (first-authenticated-wins). A worktree bounce requires two consecutive host-proven misses, since a transient desktop repo-scan rejection answers selector_not_found for a live worktree. Background network flaps no longer wake a billed relay splice, the lifecycle foreground flag stays in sync, a screen unmount cancels only its own pending host-stack transition, and diff review keeps the loaded review when its reconnect refresh rejects. Extracted mobile-endpoint-nudge-router.ts and the establisher's dialEligible pass, and split the supervisor nudge tests, to stay under max-lines. * fix(mobile): satisfy the React Doctor changed-code gate Render-phase ref writes move into effects: the protocol gate's resolved/mounted latches now record committed outcomes only (a discarded children render can no longer count as mounted), and the bounce hook syncs its callback ref in an effect. Array<T> annotations become T[] in the extracted modules. * fix(mobile): keep the loaded diff when the reconnect refetch rejects (F10) The diff-loading hook's catch was the one path still erasing a ready diff — the same keepLoadedDiff guard its disconnect and loading branches already use, now pinned by a reject-after-ready test. * fix(mobile): process foreground revival nudges --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
31 KiB
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
- 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. - Tap → bare
router.push(createMobileSessionHref(...))(app/index.tsx:740-746) targeting/h/[hostId]/session/[worktreeId]. - With the
hgroup cold (cold start, or host never visited this session), Expo Router resolves the push to the host stack's index route with nohostIdparam. 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. app/h/_layout.tsx:64readshostIdviauseGlobalSearchParams→undefined.HostProtocolGategetshostId: undefined;useHostClient(undefined)returnsstate: 'disconnected'(src/transport/client-context.tsx:344) → grey dot.- 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. - "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
- Every focus of the host screen fires
notifyForeground()(app/h/[hostId]/index.tsx:512-517, deliberately empty deps). openHostLogicalClientwraps that intoendpointLifecycle.setForeground(true)(src/transport/host-logical-client.ts:31-33); the lifecycle forwards without dedupe (src/transport/mobile-endpoint-lifecycle.ts:62-64).MobileEndpointSupervisor.setForeground(true)computeswasForeground = trueand callsRelayReconnectController.handleForeground(src/transport/mobile-endpoint-supervisor.ts:115-119).handleForegroundwithwasForeground && state === 'connected'suspends the healthy session (src/transport/mobile-relay-reconnect-controller.ts:53-60).suspendActiveRelayearly-returns unless the active path is'relay'(:77-84) — which is why LAN hosts never blink.suspendActiveSessioncloses 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.onRetry()→recoverRelay()→openRelay+migrateTo. During the dial the logical state stays 'disconnected':migrateToonly binds the new session's state afterwaitForAuthenticatedresolves (src/transport/stable-logical-rpc-client.ts:188,213), and the dialing session's ownconnecting/handshakingpublishes (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).migrateTocompletes →'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:
pair-confirm.tsx/pair-scan.tsxpassconnectOptions.onLogintostartPreProfilePairing(app/pair-confirm.tsx:91-99,app/pair-scan.tsx:135-143).- 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:connectMobileRelayForPairinghas 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 insrc/transport/pairing-relay-candidate.ts. - 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. - 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). - 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:
pair-confirmcallscloseHost(hostId)then replaces to/h/<id>(app/pair-confirm.tsx:118-123).- 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 toCONNECT_TIMEOUT_MS = 12s(src/transport/rpc-client.ts:126) on a black-holed LAN. - Relay recovery cannot start earlier:
needsRecoverytreatsconnecting/handshakingas 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). - When the direct dial finally fails, the relay dial runs invisibly (same
migrateTomechanism as S2) → green. Worst case with a director resolution failure and grace-credential retry: ~29–58s under the old session's labels. - 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, andclassifyConnectionhas 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).migrateTostructurally discards the dialing session'sconnecting/handshakingstates (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);classifyConnectioncollapsesconnecting/handshaking/reconnectingand 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) somigrateToswaps sessions with the dot staying green; only if the replacement dial fails, fall back tosuspendActiveRelayso a genuinely dead link stops lying green and the retry loop re-arms (plainrecoverRelayearly-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:
forceReconnectshould 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
openEntryinsert 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 worktreeIds
(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.relayis absent → F1/F5/F6 relay paths never activate; pairing keeps the existingmethod_not_founddowngrade (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 |