mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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:
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+28
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' }] })
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user