Commit Graph
20 Commits
Author SHA1 Message Date
Jinwoo Hong 11db2b9a7d feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key

The shell's page had no way to hear Android Back: every sheet inside it
early-returned on web, so the key popped the whole session route. Adds the
first negotiated shell-to-page frame kind alongside it.

- `back-claim`, page to shell, declared in `init.accepts`: the document is
  holding the key, or has let it go.
- `back`, shell to page, declared in `ready.accepts`: one press, dispatched to
  the newest consumer that takes it. A press nothing takes is handed back as a
  `navigate-back` rather than dropped.

Both are optional fields on frames the other side already reads, so an old
shell never hears a claim and an old page is never sent a press; each pops as
it does today. No protocol bump and no stream opcode.

`bridge-host.ts` was at its line cap, so the notify forwarder moves to
`bridge-host-notify.ts` unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): Android Back closes the sheet on the page, not the screen

Inside the shell's page every sheet early-returned on web, so one press left
the session route with the sheet still open. The drawer, the right drawer and
the file-preview prompt now claim the key through one seam on both platforms:
`use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on
the shell's. All sixteen session sheets render through `MountedBottomDrawer`,
so the one claim there covers every one of them, and a census fails if a sheet
bypasses it.

`route-handoff.web.ts` claims while the page grew a stack of its own, and
hands the press back when it did not.

The shell takes the key off the navigator only while a claim is live: Android
gets a `hardwareBackPress` handler that returns the host's own answer, iOS
loses the stack's swipe-back. The claim is cleared on `document-started`, on a
remount, on a new `ready`, on anything that takes the generation off screen,
on the page's `close` and on dispose.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): a Back press closes a sheet on the real bundle

The unit suites reach both halves of the lane but never the two together on a
document a browser rendered. The render rig can now post a `back` frame, and
the drawer check opens the Filter sheet, reads the claim off the notify list,
sends one press and pins that the sheet closed with no `navigate-back` behind
it. Red without the drawer's claim: the claim never arrives.

Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is
shared with the native app and mounts under no page provider in a bare tree,
where `usePageBridgeClient` threw; the seam now reads the bridge through
`usePageBridgeClientIfPresent` and claims nothing without one.

Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route
handoff, `bridge-page-back.ts` through the envelope.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): mirror the Back seam's latest values from an effect

Three `ref.current = …` writes sat in render, which React replays and
discards. Each moves into a dependency-list-free effect declared ahead of the
registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already
uses for the same reason: the caller rebuilds the value every render, so there
is nothing to depend on, and `useRef` seeds the first mount. The registration
still keys on the claim alone, so a rebuilt handler re-registers nothing.

The web seam's test drops its two type assertions for a named fixture type.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): the page says its Back claim again on every init

The claim was edge-triggered and the shell forgets on purpose: it drops the
claim answering every `ready`, and a host rebuilt under a live page — a client
swap through forceReconnect, which leaves the WebView mounted — starts with
none at all. A document still holding a sheet was then unknown to the shell,
and the next press popped the screen out from under it.

`init` is the shell saying it is here now, so the page answers each one with
the state rather than with a transition. Posted after the session has taken
the frame, so the gate reads that `init`'s own `accepts` and a shell that
never named the claim still hears nothing.

Nothing is said while nothing is held. Every `init` answering a `ready` comes
from a host that dropped the claim first, so it already holds false; the only
other one carries a rewritten route, where a stale true needs a `false` the
page posted to have never left, and a port that refused that frame refuses
this one too.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): a rebuilt host keeps the session's Back claim

A host is rebuilt when the client under it changes, and the page document does
not move: the WebView stays mounted, the session id holds, and the page is
never told. The rebuilt host started with no claim and no `accepts`, so it
refused every press and the navigator popped the screen out from under an open
sheet. Two clients on the same generation leave the page nothing to refuse, so
nothing made it re-ask and re-assert.

What the page declared and what it is holding are facts about the session, the
way `sessionEstablished` already is. `createBridgeHostBack` takes them as a
seed, `readSessionBack()` hands them on, and the hook holds them stamped with
the session so a record left by one never seeds the next.

`dispose()` no longer reports the claim gone: a host retiring is not a
document ending, and that report was the thing taking the key off a live
sheet. Every reset path is unchanged and still has its own case — the page's
`ready`, its `close`, and the session's own store for `document-started`,
`remounted` and anything that takes the generation off screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 16:21:26 -04:00
Jinwoo Hong 3bb9a4e261 fix(mobile): keep a painted frame under the page until its first paint (#22264)
* fix(mobile): keep a painted frame under the page until its first paint

The shell tore its own frame down the moment a generation was on screen
(`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws
nothing until its document paints. What showed for the whole of the page's boot
was the surface behind it with nothing on it: 1.42 s on a cached generation,
against a one-frame budget.

The page is the only thing that knows when it has a frame, so it says so. It
declares `painted` in `ready.reports` and posts the notify after the browser has
painted its first commit; the shell holds the same neutral frame it was already
painting while it opened the generation, then fades it out. The wait is bounded
by the declaration and never by a timer: a generation served by an older desktop
declares nothing and is uncovered on `ready`, which is what every shell did
before this.

iOS painted white rather than nothing: a WKWebView is opaque by default, so the
shell's own surface never showed through. It is now transparent, as the Android
view already was.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the cover through the compositor handover

The page reports the paint its own renderer made; putting that on the app's
surface costs another frame or two. A linear fade from the report left two
frames of bare surface between the two on an emulator, which is the hole the
cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity
across that handover: five reopens now show 0-21 ms of bare surface against
102-2043 ms on the build without it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): negotiate the paint report in both directions

The page posted `painted` whatever shell it met, and `notify` is a closed union:
every shell installed before this answered it with an error frame, once per
mount. The shell now advertises the name in `init.accepts` beside the param
clear and the client identity, and the page posts only when it was advertised.
The declaration in `ready.reports` stays unconditional, because it is an
optional field an older reader strips rather than a new opcode, and because the
first `ready` — the only one that matters for the first paint — is sent before
any `init` has arrived.

The accepts list moves into `bridge-init-frame.ts` beside the grants, which is
the module that builds the frame carrying it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the cover's colour instead of asserting its shape

Two gate findings on the round-two head. The cover test reached the background
through a cast of the style prop; it now reads it through a checked narrowing,
so the test proves the shape it depends on rather than declaring it.

`use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args
gained `onPagePainted`: its harness is a literal, so a new required handler is a
missing property. The probe now counts paints and one case spends the counter,
which is what a handler wired only to satisfy a type would not do.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move the cached-generation opening out of the reducer

`mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused-
update work and the paint handling both grew it. What comes out is one thing —
putting a generation already on disk on screen, and deciding whether this route
is one that bundle carries. It is the reducer's cache path and its refused-
update path both, and it was already three functions sitting together.

`step` goes into a module of its own because the two now share it; a copy in
each would be two spellings of one transition, and exporting it from either
would point the dependency the wrong way.

No behaviour moves: the reducer's table tests are unchanged and the page closure
is unchanged at 4,211, since neither new module is reachable from a page route.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop the previous document's paint when a new one starts

A document that replaced a painted one inside the same mount inherited its
`pagePainted`, so the cover lifted before the replacement had drawn anything.
The native view already reports `loading`; the screen dropped it. It now
reaches the reducer as `document-started` and clears the page document state.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): make the declaration case call the frame policy

The case compared the name to itself and never called `shellPageFrame`, so it
passed for a policy that ignored the declaration entirely.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report the page's frame from the route screen, not the router

Every route screen is behind `import()`, so the wrapper above expo-router
commits with a suspense fallback while the chunk is still arriving. The paint
report hung there, which uncovered the shell's view over an empty body on a
cold chunk. It now hangs on the screen the manifest resolves, layouts excluded.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): retire the readiness wait a replaced document armed

`document-started` cleared the page document state and left the flow alone, so
the previous document's readiness deadline passed the flow check, read
`pageReady` as false and failed a session whose replacement was still loading.
The flow moves with the document, for the reason `remounted` already moves it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let a departing route screen take its paint report back

The report waits two frames, and nothing cancelled the second one, so a screen
unmounted in between still told the shell to uncover. The reporter now answers
with a take-back the wrapper returns as its cleanup, and the once-per-document
latch frees only when a report was cancelled before it landed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the screen that arrived take over a frame still owed

A screen committing inside the two frames an earlier one was owed found the
latch taken and reported nothing; the earlier screen then freed that latch on
its way out and nobody was left to lift the cover. The newest commit now
supersedes the pending report, and only a posted one spends the latch.

Covers the redirect window with a render check against the pr route, whose
target chunk is held open while the document sits on the hub's fallback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): make the take-over case turn on the take-over

The case cancelled the first screen's frame through the cleanup path, so it
passed with the take-over deleted. It now leaves that screen mounted and reads
the clock: the frame after the replacement commits is the replacement's first,
not the one the screen behind it was still owed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 11:43:26 -04:00
Jinwoo Hong 66ade30576 fix(mobile): the page's live input stays above the soft keyboard (#22252)
* fix(mobile): the page's live input stays above the soft keyboard

Edge-to-edge makes the manifest's `adjustResize` inert, so the window never
shrinks for the IME and the page's `visualViewport` reads full height with the
keyboard up: the session route laid its live input row out under the keys. The
shell owns the window, so it shortens the WebView by the keyboard instead.

The session screen's own `Keyboard.addListener` pair never fired on
react-native-web, so the page also never held off the terminal refit. Both facts
now come from the platform seam, which answers them separately on the page: the
keyboard is open, and it covers nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the session keyboard harness without a cast

The hoisted keyboard now carries the seam's own `SoftKeyboardState`, and the
mocks the screen is handed carry the types it calls them with, so nothing is
asserted into shape.

The scope the harness builds could not be spelled at all before: the hook took
the whole lifecycle model to read 28 of its fields. It now names those fields,
which the model still satisfies, and the test builds one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 09:06:45 -04:00
Jinwoo Hong 3d76c22b57 fix(mobile): a refused update serves the cached generation while the host is up (#22237)
* fix(mobile): a refused update serves the cached generation while the host is up

A newer generation that fails to fetch or stage was refused with a named
reason and then painted a wall with "Try again" over an intact generation
already on disk — the same one the offline branch opens without being asked
the moment the host goes away.

`onDownloadFailed` now branches on what is cached rather than on which side
refused: with nothing on disk the refusal is still the screen, and with a
generation on disk it is opened through the offline branch, judged by its own
route list. The refused generation is never staged, committed or persisted, and
nothing about the refusal is written, so the next launch asks the host again.

The bundle-side refusal is named as a dismissible notice above the page, on the
existing host-route banner. It says what happened and promises no retry,
because the shell schedules none.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): judge the cached generation against the host it can reach before serving it

The fallback served a cached generation on the strength of the offline rule,
which skips the compat check because a host nobody can reach cannot have
changed. On this path the host has just answered, and an update usually exists
precisely because it moved — so bytes that were inside the protocol window when
they were written may be outside it now.

`CachedGeneration` now carries the three fields the compat verdict reads,
projected in `openCache` off the manifest stored beside the assets. That
manifest is never absent: `readActiveGeneration` answers null for a generation
whose manifest did not parse, and the read schema requires all three.

`cachedGenerationWall` lives beside `gateVerdict`, because only an `open` gate
is judged further. The other verdicts already have answers there: an absent
capability is the native-route rule, and an unreadable status leaves the same
empty list, so walling on either would be the `bundle-unavailable` wall that
file exists to keep off a host that simply did not reply.

A generation outside the window now earns the wall with its verdict, not the
download-failed screen, and nothing is deleted: the bytes are intact and a
newer host is not what makes them wrong.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): ask the cached generation's own routes before walling it

The compat wall ran before the route question, so a cached generation that does
not carry this route — or predates route listing entirely, `routes: undefined` —
earned a terminal `bundle-incompatible` wall where the answer is `native-route`.
`onManifestRead` has always taken the other order: a route that stays native has
nothing to wall about. `openByOwnRoutes` now asks the route first and applies
the wall only on the served branch, and the update notice moved to `served`, so
a native answer carries no notice about a screen it is not showing.

The other half is the verdict the gates hold at the moment of the refusal. A
`fetching` session does not await the gates, so a refusal can land under a
verdict the flow never started on. `gateState` is now the one mapping from a
gate verdict to a screen, shared by the entry into the flow and by the fallback,
so the two cannot answer the same verdict differently: a host that stopped
serving a bundle is `native-route`, a status that went unreadable says so and
re-arms, a dial in progress or a pending status waits in `checking`, an
unreachable host keeps the offline rule and serves the cache unjudged, and
`open` is the only answer that leaves a host to judge the generation against.

That inverts two round-2 assertions that expected the cached page to be served
when the capability list had gone empty. Both were wrong for the same reason:
an empty list is the gate's question, not a verdict about a bundle.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): announce the host route notice banner, as loudly as its tone

The banner is inserted into a screen that is already on screen, so a reader who
has moved past the top of the list never arrives at it. It carried no live
region and no role, so nothing carried it to them.

The urgency follows `tone` rather than being assertive for everything. The
failure tone is an action that did not happen — a refused worktree action, or
the shell's refused update — and interrupts with `alert` and an assertive
region. The notice tone is a bounced route, context for a list already being
read, and waits its turn politely; interrupting for that would train people to
ignore the first. No role on that arm: React Native has no `status` role, so the
polite region is the whole of the answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 08:39:21 -04:00
Jinwoo Hong 9267423f22 refactor(mobile): the bridge host mount reads one args ref, and the handshake fact lives in the session (OTA phase C, audit item 3) (#22080)
* test(mobile): pin every bridge callback to the render on screen

The host is built once per session, so each callback it holds has to reach the
render that is on screen rather than the one that built it. Only the page fault
was pinned that way; this covers the eight a frame can reach, and asserts the
host was not rebuilt for the re-render by leaving a request open across it.

Green before the collapse that follows: the pin is an invariance guard, not a
reproduction. Verified to fail when a callback is closed over instead of read.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read the bridge host's props through one args ref

Sixteen latest-callback and route mirrors, sixteen assignments and a sixteen-
entry dependency list become one ref holding the whole props object, written in
one layout effect with one dependency, and one adapter built with the host that
reads it at call time. Adding a callback was a three-place edit the React Doctor
gate has caught four people missing; it is now one entry in the args type and one
line beside the host.

The props type is named for that: the host is built against the whole object.
`viewRef`, `hostRef` and `establishedSessionRef` stay, as do the host's own
`serving`, `initSent` and report-once flags.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the handshake fence to the session, not to the mount

Red on this head, in three places. The bridge hook has no way for a caller to
say the session is already open, so a page whose host was rebuilt for a new
client is refused; the session hook does not report the handshake its reducer
already records; and the screen has nothing to hand over.

The second bridge case is the control: the same frame on a session the caller
says nothing about is still refused, so the first one is the fence moving rather
than the fence going.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move the bridge handshake fact into the session it describes

`establishedSessionRef` kept "this session has completed a handshake" for the
life of one mount, so the fence a rebuilt host inherits was remembered beside
the session rather than by it. The reducer already records the same fact as
`pageReady`; the session hook now reports it and the screen hands it over, so
the bridge host takes it from the render.

`options.sessionEstablished` stays. It is the host's own seed for `initSent`,
which is the fence a rebuild has to inherit, and the red cases above are what
say so.

The shared bridge harness gained the screen's half of that: it records the
handshake on `onPageReady` and re-renders, which is what the client-rebuild case
was reading off the deleted ref. No existing case body changed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop the dependency list from the bridge args refresh

A caller builds the props object inline, so every render is a new one and there
is nothing for the list to compare; React Doctor reads that as a dependency
recreated each render, and it is right that the list says nothing. No list is
what the ref is for: it runs after each commit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 17:35:05 -04:00
Jinwoo Hong 3cfb070294 feat(mobile): register the session page route (OTA phase C, C7.7) (#21977)
* feat(mobile): switch the session route to the shell, still unregistered (OTA phase C, C7.7)

The review switch's shape, for its reasons. The session screen becomes
`MobileSessionRouteScreen` in `src/session` because `useMobileSessionController` is 32 hooks
deep and opens the terminal, chat and tab subscriptions: at the switch's top level it would
open every one of them behind the page as well as in front of it, since hooks cannot be
conditional. As an element passed for `fallback` it is built and not mounted.

Four query params carried rather than re-derived, each omitted when empty: `name` is a label
the screen otherwise derives from the workspace, `created` is the create flow's one-shot flag,
`warning` is the host's own text, and `paneKey` is a notification tap. `paneKey` is the one the
screen writes back — `use-notification-pane-navigation.ts` rewrites it to empty once it has
switched, through `setParams` on the handoff, which inside the page is the document's own
router — so it has to arrive in the page for that to happen at all.

Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists
it; until then the flag is the only thing that changes and it is off.

Three censuses red without their rows, measured on this tree:
- `shell-screen-route-census.test.ts` `walks the route tree and finds them` named
  `session/[worktreeId].tsx` as a ninth switch the list did not have.
- `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no
  others` reds without `SESSION_ROUTE` in `SWITCHED_ROUTES`.
- `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` named the
  new sibling.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): root the session parity family at the screen the route mounts (OTA phase C, C7.7)

The extraction parity pin walks from a root function in `app/h/[hostId]/session/[worktreeId].tsx`,
which is now the flag switch: the walk found no `SessionScreen`, and the runtime-string count went
534 -> 542 on the switch's own param names and path literals.

Rooted at `MobileSessionRouteScreen` instead, which is the function that calls the controller. The
switch's business is which of the two screens renders, not what the session screen does, and its
literals have no place in a hash about the extraction.

Every pinned hash is unchanged, which is what says the body moved and nothing else did: 275 hooks,
77 callbacks, 24 effects, 534 runtime strings, 124 host and 61 leaf JSX facts, 172 style
references, all at the same SHA-256 they had before the move.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the page the session screen's stored preferences (OTA phase C, C7.7)

Ruling 7: nothing silently no-ops. The allowlist was one exact key and one prefix, so every
preference the session screen reads inside the page fell back to its default and kept working
outside it — a state the user cannot tell from a preference that does not exist.

The keys are derived from the route's own closure, not copied from design §6. Nine join the list:
`orca:terminal-accessory-layout`, `orca:custom-accessory-keys`, `orca:defaultSessionView`,
`orca:mobileStructuredSendOperations:v1`, the three terminal preferences ruling 7 names
(`orca:terminalTextScale`, `orca:terminalAutocompleteEnabled`, `orca:terminalLinkOpenMode`), and
two the design did not: `orca:hostDockWidth`, which `use-mobile-dock-resize.ts` drags on this
screen, and `orca:hostSidebarWidth`, which `app/h/_layout.tsx` reads above every page route and
which the manifest already names as the reason agent-history declares `storage` at all.

Two are per workspace, not per host. Design §6 has `orca:nativeChatTabs:<worktreeId>`; the module
builds `<prefix><enc(hostId)>:<enc(worktreeId)>`, and `orca:terminalLiveInputDisabled:` has the
same shape. So the narrowing goes one level in from C2.9's: `pageStorageKeysForRoute` and
`isPageStorageKeyForRoute` replace the host-scoped pair, and a session page opened on one workspace
can no more rewrite the tabs of the one beside it than it can another host's pins. Both sides read
the workspace off the route pathname, which is the one fact the shell and the page are each handed.

Every new key's writer notes the mirror before it persists, as `savePinnedIds` does: `init` is
built synchronously, so a write that only reached the store would be one `init` behind.

A refusal is a rejection, not a dropped write. The real AsyncStorage rejects when its store
refuses, and the caller that matters already catches: the durable send journal answers
"Message not sent" rather than putting a mutation on the wire with an operation id no store holds,
which after a crash would send the message twice. `PageStorageRefusedError` names the key and which
of the three refusals it was.

Measured on this tree, which is why the journal needed more than an allowlist entry: one journal
entry with no attachment serializes to 342 characters and 48 unsettled sends put the value past
`PAGE_STORAGE_MAX_VALUE_CHARS` (47 is under it), against a schema that admits 4,096. `init`'s own
`BridgeInitStorageSchema` refines on that bound, so handing the journal over whole refuses the
*frame* and the session screen never opens at all. `pageStorageEntriesForInit` drops such a value
and names it; the page reads a default, which is a degradation rather than a page that does not
start.

Red first, measured here:
- 21 cases across three files on the host-scoped helpers being gone.
- `leaves out a value the page would refuse the whole frame over` reds with the filter bypassed.
- The journal case reds without the rejection, with the operation claimed against a store that
  never took it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): register the session page route (OTA phase C, C7.7)

One entry in `MOBILE_WEB_PAGE_ROUTES` with ten grants, every one read off a call site in this
route's own closure rather than carried from design §1. Measured here:

  navigate                 7 handoff sites
  externalLink             6 openers
  haptics                 24 trigger sites
  native.clipboard.write   6 sites
  native.clipboard.read    3 sites
  native.media.*           2 sites, one seam (`useMediaPicker`)
  screencastBinary         1 site (`MobileBrowserPane.tsx`)
  storage                 10 exact keys and 2 workspace-scoped, the previous commit's

`pageRouteGrants` is derived from this list, so the row is a consequence of the entry and there is
no second table to edit. The design's list was exactly right; the counts are what say so.

The hop census goes 16 -> 23, measured. All seven new rows are `X -> /h/[hostId]/session/
[worktreeId]`, one from each other page route, and none goes the other way: the session's ten
grants are a strict superset of every other route's, so every hop into it is handed to the shell
and every one of its own targets stays in the document. That second half is asserted as grant
coverage rather than as the absence of seven rows — absent is also what an unregistered route
looks like, which is the shape C4 already had to correct once.

Two censuses gained the route and one is new:
- The haptics seam census, whose route-module map moves to
  `mobile-web-app-page-route-modules.mjs` so the new census below shares it rather than keeping a
  second copy that stops growing when the first one does.
- `page-served-back-control-a11y.test.ts`, which named two controls with no `accessibilityRole`:
  `MobileSessionHeader.tsx:64 role=none label=Back to worktrees` and
  `QuickCommandsSheet.tsx:160 role=none label=Back`. Both get the role. Inside the shell there is
  no native chrome behind them, so a bare Pressable is absent from the accessibility tree.
- `mobile-web-app-screencast-lane-grant.test.mjs` derives `screencastBinary` from the closures the
  way the haptics census derives its token. C6 could not write it: the pane is mounted by a route
  rather than registered as one, so there was no route to pin the grant against (C6 ruling 3).

The derivation census gains C6's half measured against this route rather than against a module
closure read on its own, which is the other half of C6 ruling 3. The composed row for the session
route's own families waits on C7.8's table, and on C4.5's split before it.

Numbers, both ends measured on this tree, never summed:
- Session route closure 4,328 -> 4,329 modules, 978 -> 979 local. The +1 is
  `MobileSessionRouteScreen.tsx`; the route file is one input either way, now the `.web.tsx`.
- Chunk count 65 before and 65 after, against the 72 the fence allows at 14 route keys. The fence
  is untouched: a `.web.tsx` sibling is not a new route key, and this route shared its split.
- Bundle 8,020,519 -> 8,022,202 bytes, 108 assets either side.

`mobile-web-app-route-chunk-closure.mjs` looked the route module up by its exact path, and
`resolveExtensions` puts `.web.tsx` first: the first route with a sibling to be asked for reached
"no output". It tries the sibling first now, which is what the build actually chunked.

Without the manifest entry these red on this tree: `pins every hop the handoff must take away from
the page`, `keeps every hop out of the session local`, `declares only routes the bundle has a
module for`, `reaches the built manifest`, `covers every page route and finds a control in each`,
both haptics-seam cases, and `declares the screencast lane on exactly the routes whose closure
asks for it`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): render-check the session route, and quiet the two things it found (OTA phase C, C7.7)

The render check mounts the registered route in a real browser on the built bundle, under the
header the shells send. It asserts the session screen paints rather than the Unmatched route, that
the Back control reaches the accessibility tree as a real `<button>` with its name, that the
route's own chunk arrives on a client-side navigation, that nothing it paints leaves the origin or
logs a policy violation, and that the three reads the screen makes carry the workspace the route
named — the precondition the rest needs, since a screen that mounted and asked for nothing would
paint the same chrome.

It also asserts, strictly, that the page and console errors are `[]`, which is what found both
fixes here. Measured on this tree before them: two console lines and one uncaught rejection on
every mount of the route, none of them visible natively.

- `use-mobile-session-markdown-actions.ts` registered `BackHandler.addEventListener` with no
  platform guard, and the effect re-registers whenever the dirty-draft list changes. React Native
  Web answers "BackHandler is not supported on web and should not be used." and hands back an inert
  subscription, so the guard was never armed on the page anyway. Gated on `Platform.OS`, as the
  right drawer, the bottom drawer and the file preview already are. There is no hardware back in a
  WebView; the shell owns the phone's, and the page's Back control is where the prompt lives.
- `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch.
  The loader returns on a *refused* `worktree.show` and nothing caught a *rejected* one, so a host
  that will not answer produced `Uncaught (in promise)` on every session mount. Caught at the
  effect rather than inside the loader, whose promise the golden recorder awaits; notes that did
  not arrive leave the ones on screen as they were, which is the module's own policy for a refusal.

**The terminal is not painted here and the file says so at both ends.** A terminal on screen needs
the host protocol handshake, a tab snapshot, a terminal inventory and a `terminal.subscribe`
stream — five hand-written fixtures against five Zod schemas inside a transport double, which is
what the harness's docstring refuses to become. Scripting `status.get` alone was measured here:
the protocol gate reads it and the page paints "Update Orca on your computer" instead of the
screen. What the terminal does under the shipped header is
`mobile-web-app-terminal-render.test.mjs`, on the same component and the same build options.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refresh the session parity pins for the two seam edits (OTA phase C, C7.7)

The previous commit's two fixes are inside the parity family, so three pins moved. Both edits are
one token each and neither changes what a phone renders:

- `'web'`, the `Platform.OS` guard the Markdown actions' `BackHandler` registration gained.
- `"button"`, the accessibility role the session header's Back control gained.

Runtime strings 534 -> 536, with the effect hash and the host-JSX hash moving for the same two.
Everything else is unchanged: 275 hooks, 77 callbacks, 24 effects, 61 leaf JSX facts, 172 style
references, all at the SHA-256 they had before. A separate commit because a reported head does not
move by amend, and because the moved hashes are worth reading on their own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report the diff-notes rejection instead of catching it (OTA phase C, C7.7)

The `.catch` the previous commit added to `loadDiffComments` moved a golden, which is a finding
rather than something to record over: `matrix-session.diff-notes-worktree.show-1` certifies the
unhandled rejection as an effect of its loaded checkpoint, so the corpus says the app raises it
today and a fix is a re-record and a review event.

Reverted to `void loadDiffComments()`, with the defect written where a reader of that effect will
find it. `family-recordings.test.ts > session.diff-notes: reply partitions at worktree.show#1` is
green again; it was the one failure in an otherwise clean 8,699-test run.

The render check keeps the observation rather than losing it. Its error assertion is now the exact
list `['RenderCheckShellDouble: the render check answers no RPC']` instead of `[]`, so a second
error reds it and so does this one going away — which makes the file the place the fix is noticed
when someone lands it with the re-record.

The defect, for that PR: the loader returns on a *refused* `worktree.show` and nothing catches a
*rejected* one, so a host that will not answer raises an unhandled rejection on every session
mount. It is not a page fault — the shell's `fault` notify comes from the React boundary and
nothing reaches it — so the generation is not dropped and the screen works; the cost is a
document-level error on every mount.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the session effect hash after the diff-notes revert (OTA phase C, C7.7)

The effect pin was refreshed while `loadDiffComments` carried a `.catch`; reverting that (the fix
moves a golden, so it is a finding rather than a line) moves the same hash back off it. Repinned on
the uncaught `void` call, which is what the tree holds and what the corpus certifies.

Count unchanged at 24 effects; nothing else in the family moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): reject a page storage write for size only, and log the rest (OTA phase C, C7.7 round 1)

Ruling 33.4. `PageStorageRefusedError` was raised for all three refusals, and two of them have no
catcher: a page-closure writer of an unlisted key awaits `setItem` with nothing around it —
`notification-delivery-preferences.ts:39` plainly, `preferences.ts` in several places — so a key
the page was never allowed to keep became an unhandled rejection in the document. That is a worse
failure than the silent drop it replaced, and it is the one the page can least afford, because an
uncaught rejection there is a document-level error on a screen that is otherwise working.

Scope is now one refusal. `too-large` rejects, because the caller that needs it is written for it:
the durable send journal's composer catches it and answers "Message not sent" rather than sending a
mutation whose operation id was never written down (ruling 7). `not-allowed` and `not-delivered`
resolve and are logged as `[page-bridge] storage-write-dropped`, which is the old behaviour plus
the line a device log needs — a preference that did not stick looks identical to one nobody set.

A batch applies every pair it can, logs every drop, and rejects only if one of them was oversize.

Red first, measured here: seven cases in `page-async-storage.test.ts` red on the rejection, among
them a `notificationDeliveryPreferences` write resolving, another host's pins, another workspace's
chat tabs, and a write the shell would not take. The oversize case is unchanged and still asserts
`PageStorageRefusedError` with the key and the character bound in its message, so the narrowing is
visible as the difference between the two.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): make every count in the session route say the same number (OTA phase C, C7.7 round 1)

Ruling 33.5. Three numbers were stated more than once and two of them had drifted when the merge
took the grant list from ten to fourteen.

- Grants. `mobile-web-page-routes.mjs:100` and `mobile-web-page-route-hop-coverage.test.mjs:57`
  both still said ten. Fourteen in both, and the manifest comment now names the audio verbs beside
  the media ones as things only this route asks for.
- Keys. The manifest said `storage` covers "the ten exact keys and two workspace-scoped ones",
  which counts `orca:last-visited-worktree` — a key this route did not add. Nine exact plus the
  two workspace-scoped, which is what C7.7 put in `page-storage-keys.ts`.
- The journal entry. 342 and 343 are both real and answer different questions, which is exactly
  why one number had to win: an entry serializes to 342 characters on its own and costs 343 in the
  array, the difference being the comma that joins it. 343 is the one that drives the threshold,
  so it is the one stated, with the 342 kept beside it as its derivation. Re-measured here rather
  than carried: 47 entries are 16,140 characters and 48 are 16,483, against the 16,384 cap.

Comments only; no behaviour and no assertion moved. The threshold case in
`mobile-structured-send-page-storage-refusal.test.ts` already asserted the boundary both ways and
still passes unchanged, which is what says the arithmetic above is the code's and not the prose's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): red-first for a pane request over a re-sent init (OTA phase C, C7.7 round 1)

Ruling 33.1's four cases plus the compatibility one, all red: `publishRoute`
is not a member of the host, `onRouteUpdate` is not a member of the page's
client, and `ready` carries no `accepts`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): deliver a pane request to the mounted page over a re-sent init (OTA phase C, C7.7 round 1)

Ruling 33.1. The session switch keyed on the whole route, so a notification
tap for another pane of the session on screen either remounted the shell (a
bridge teardown and a page reload for a tab switch) or, for the pane already
showing, moved nothing at all: the page cleared `paneKey` on its own router
and the native param kept it, so `SET_PARAMS` wrote the value already there.

`paneKey` leaves the key and travels as a route update. The page declares
`accepts: ['route-update']` on `ready`; the shell re-sends `init` for a
same-path param change only to a page that declared it, and treats a second
`init` for the session the page already holds as a route update rather than a
replacement -- in-flight requests, subscriptions, the storage snapshot (the
same object, asserted) and the generation all stay. The screen reports
delivery and the switch clears the native param, so no later `init` replays a
spent tap. `use-notification-pane-navigation.web.ts` reads the request off a
standing listener; the native file is unchanged.

Wire-compatible both ways without a version bump: `accepts` is optional, an
older page is never sent a second `init`, and an older shell never sends one.
Both degrade to today's lost repeat tap. `BRIDGE_PROTOCOL_VERSION` and every
released native RPC are untouched.

Two files were at their line cap, so two modules came out at their own
boundaries rather than a cap bump: `bridge-init-route.ts` (the route half of
`init`, wanted by the switches, the host and the page) and `bridge-host-route.ts`
(one host's held route and what it may publish).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the session switch and its hardware-back gate get their own tests (OTA phase C, C7.7 round 1)

Ruling 33.2. `mobile-web-shell-session-route.test.tsx` mirrors the eight cases
the files switch has -- route built, native fallback while the flag settles,
repeated params, dot-segment refusal, segment encoding, flag off, remount on a
route change, remount on a param change -- plus the two pane cases: a repeat
tap for the same pane reaches the mounted page twice and a different pane
reaches it once, both with one mount in the lifecycle.

The `BackHandler` gate gets a unit test in the shape of its three siblings.
Reaching it meant the hook declaring the fourteen fields it reads instead of
taking all 268 of the session model, so a probe can render it without building
a session; `MobileSessionDiffCommentsModel` satisfies that by construction and
the one caller is unchanged. No pin in the session parity census moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): a call-site census for the six grants that had none (OTA phase C, C7.7 round 1)

Ruling 33.3. `navigate`, `storage`, `externalLink`, the two clipboard verbs
and the media three were pinned only by the list they were copied from, so
striking any of them out of a manifest entry reddened nothing. Each is now
derived from the route's own closure by parsing the call sites -- a call, not
a mention in a comment or a string, and not an import the module never calls
-- and each row has a named control case driven over the session entry with
that row's grants struck out.

It found one thing. `app/h/_layout.tsx` wraps every `/h` route in
`HostProtocolGate`, whose wall offers an Update Orca link through
`openExternalLink`, and two routes reach that without declaring
`externalLink`: on them the link posts a notify the shell refuses. Recorded
exactly as `KNOWN_UNDECLARED` rather than exempted, because widening two other
routes' grants is a capability decision and this is pre-existing on main.

`notificationPaneTab` moves to its own module. A `.web.ts` sibling cannot
import its native neighbour by the plain path: the bundler's
`resolveExtensions` answers with the `.web.ts` file, so that import was the
file itself and esbuild refused the page bundle with a cycle. The mobile suite
does not bundle, so only the closure walk saw it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a struck-out grant red a case named after it (OTA phase C, C7.7 round 1)

The first shape checked the whole manifest at once, so removing any one of
the eight reddened all seven cases and named none of them: the per-row control
read `session.grants` off the manifest the removal had just changed. Each row
now has its own manifest case, and each control is built from what the
session route's closure reaches rather than from what its entry declares, so
it stays green whatever the manifest says. Closures are memoised, which is
what pays for walking all eight once per row.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the pane request in a ref, not in state (OTA phase C, C7.7 round 1)

The changed-code quality gate's React Doctor found it:
`no-adjust-state-on-prop-change`. A tap can arrive before the terminals have
loaded, so the request has to wait; holding it in state meant the effect that
consumed it set state on a prop change, and the stale selection renders first.
The request waits in a ref now and a counter wakes the effect, so the effect
reads and clears rather than adjusting anything.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): re-measure the session closure and list the new web sibling (OTA phase C, C7.7 round 1)

The full `config/scripts` suite found both. The closure reads 4,326 modules
and 984 local, two more than the merge, and the two are named rather than
counted: `notification-pane-tab.ts` and `bridge-init-route.ts`. The pane
hook's web sibling replaces the native file rather than joining it, so it
costs nothing -- but it is a `.web.ts`, so it needs its row in
`web-overrides.json` saying why the native one cannot run on the page.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the page off a journal init could not carry (OTA phase C, C7.7 round 1)

Ruling 33.6, from pullfrog on `beda1cc384`. Dropping an over-cap value from
`init` did not revoke the page's write access to it: the key stays in
`pageStorageKeysForRoute`, so the page read no journal, `parseJournal(null)`
gave it an empty one, and its first send wrote a one-entry value over the
device's -- every native entry lost and a fresh `operationId` for an operation
the native journal already held, which is the duplicate send ruling 7 exists
to prevent.

`pageStorageEntriesForInit` now reports `oversize` beside `dropped`: only the
value-cap drops, because an entry-cap drop is a key that fits and the page's
own write of it is the size the shell would have carried anyway. The shell
sends those names as `init.storageOversize`, and a page write to one of them
rejects with `PageStorageRefusedError` under the size contract of 33.4, which
the composer already shows as "Message not sent". The native journal is
untouched until the user is back on native or it drains.

`storageOversize` is optional in both directions: an older shell sends none
and an older page ignores it, which is exactly today's behaviour. No version
bump; omitted rather than sent empty, so no golden moves.

Red first, with the two states replaced by ones the shell produces. The 47/48
case drives `pageStorageEntriesForInit` rather than publishing a journal value
the shell strips before `publishPageStorage` ever sees it, and the case that
used to assert a successful write now asserts the native entries survive: it
was the clobber, recorded as success.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): deliver a route update only when a param moved, and only after init went out (OTA phase C, C7.7 round 2)

Round 2, findings 1 and 2, both red first.

`onRouteUpdate` fired on every re-sent `init` for the session the page holds,
not only on one whose route moved. The shell answers every `ready` with the
route it holds and the page re-asks on its own backoff and again after a
refused `state` frame, so one tap reached the pane hook as `['', 'pane-1']`.
Both ends now read one definition of moved, `bridgeRouteMoved`, which is the
page's own `shellScreenRouteKey`: the host will not send an `init` for a route
that did not move and the page will not publish one it was sent anyway. The
`.web.ts` hook keeps its empty-pane guard and its comment now says why it is
load-bearing rather than defensive -- the shell's own clear arrives as a move.

`onRouteDelivered` ran on the `ready` path without checking that an `init` had
gone out. A refused route answers the ask with nothing, so the caller would
clear a one-shot param the page never received. `sendInit` reports whether a
frame left and `onPageReady` carries it. Unreachable from the session switch,
which parses the route before it mounts the shell; the prop's contract says it
anyway, and the publish path already honoured it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): attach the init-storage doc to its type, keep the overrides escape (OTA phase C, C7.7 round 2)

Round 2, findings 4 and 5, neither a behaviour change.

The block describing `pageStorageEntriesForInit` had `PageStorageForInit` and
its own one-line doc between it and the function, so it documented neither.
The type moves above it and the block sits on the function it describes.

`web-overrides.json` had an escaped em dash re-encoded as a literal one when
this branch added its rows through a JSON round trip, on a line about the
keyboard stub that has nothing to do with C7.7. Main's `—` is restored;
`oxfmt --check` accepts the file either way, so this is main's spelling kept
rather than a formatter's demand.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): catch the custom-key save the page store refuses (OTA phase C, C7.7 round 2)

Round 2 addendum. `addKey` awaited `saveCustomKeys` with no catch and both of
its callers are `void addKey(...)`, so the rejection had nowhere to go.
`orca:custom-accessory-keys` is in the session route's page allowlist and a
page write over `PAGE_STORAGE_MAX_VALUE_CHARS` rejects rather than drops (the
size contract of 33.4, extended by 33.6 to a key `init` could not carry), so
past ~16 KB of accessory keys this surfaced as an unhandled rejection in the
page -- which the fault boundary reports and which drops the generation.
Every other allowlisted writer in this closure already catches: the two write
chains in `TerminalShortcutSettings`, the live-input save and the session-view
preference.

Caught at the boundary and logged, and the drawer neither announces the key
nor closes: a row on the accessory bar that no store holds, gone at the next
load, is the failure the allowlist exists to avoid. Red first -- the case saw
the refusal escape with the page's own message -- and the control reds again
when the catch rethrows.

Belongs in `8b4c559e90` by the brief; it is its own commit because that one
was already made and amending is forbidden.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): reject a batch of oversize writes once, not once per pair (OTA phase C, C7.7 round 2)

CodeRabbit and pullfrog, same site. `settleBatch` called `settle` per refusal
and kept the first rejected promise, so a `multiSet` or `multiRemove` with two
over-cap pairs built a second rejected promise nobody held -- an unhandled
rejection in the page, the outcome ruling 33.4's rejection scope exists to
avoid. Two oversize keys is all it takes, and `storageOversize` made a second
way to reach it.

A refusal is now an error or nothing, and only the caller's one rejection ever
becomes a promise. Red first under an `unhandledRejection` listener with two
over-cap pairs: one orphan before, none after, and the caller still hears
about the first key.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): record a route key only once a frame carried it (OTA phase C, C7.7 round 2)

CodeRabbit on `MobileWebShellScreen.tsx:271`. The effect recorded the route's
key and then published, so a publish the hook refused for having no host was
remembered as though it had gone out. `publishRoute` is now keyed on
everything the host is built from rather than on the session alone, so the
render that brings the host re-runs the effect, and the key is written only
after a frame has left.

Reported honestly: this does not repair a lost tap, and the case beside it
says so. The host is built from the route the render holds, so a route that
moved before it existed rides the first `init` either way and `publishRoute`
then answers "did not move". What the change removes is a key recorded for a
frame nobody sent -- the same contract finding 2 fixed on the `ready` path.
The case pins the delivery count across the gap: nothing reported while there
is no host, nothing reported once there is one and it has sent nothing, and
exactly one report when the `init` answering the page's ask carries the route.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse an oversize-dropped key at the shell, not only at the page (OTA phase C, C7.7 round 2)

pullfrog's rollout gap on ruling 33.6. `storageOversize` is honoured by a page
built with it, and the page is served from the desktop: a document from an
older bundle ignores the field and writes the key whole, which for the send
journal replaces every entry the device holds. The shell is the half that
updates with the app, so the shell is where the refusal has to live.

The host now refuses a `storage` notify for a key it could not hand the page,
answering it as the drop it already answers an unlisted key with. The page's
own rejection stays as the fast path -- it reaches the composer as
"Message not sent" with no round trip -- and the schema comment says the field
is advisory and the shell enforces it.

Red first: a host holding the journal as oversize received a page write for it
and posted it to native storage; now it posts nothing and the entries survive,
while a key it did hand over is still writable.

The three refusals became one predicate in `page-storage-keys.ts`, where the
keys are, because inlining the third put `bridge-host.ts` over its line cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the page's pane hook gets its own test (OTA phase C, C7.7 round 2)

pullfrog: `use-notification-pane-navigation.web.ts` had no cover. The native
file's test mounts the native file, and `bridge-route-update.test.ts` stops at
the client, so the half that turns a route update into a tab switch was
untested.

Seven cases: the seed from the route the page was opened on, a request held
until the terminals load, a repeat tap on the pane already showing, a
different pane, the clear the shell posts after each delivery, a pane that has
since closed, and a page opened on no pane at all.

Two controls, so the cases are not all satisfied by one behaviour. Dropping
the seed reds the two that read the first `init`. Deduplicating by value
instead of counting deliveries reds the repeat tap, which is the case the
counter exists for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): roll the custom-keys mirror back when the store refuses (OTA phase C, C7.7 round 2)

CodeRabbit. `saveCustomKeys` notes the write in the mirror before it persists,
because a reader is answered from the map rather than from the store and the
shell builds `init` synchronously from that map. On a refused write the note
stood: the page's next `init` carried the value native had rejected, and every
native reader of the key saw it too.

The previous mirrored value is captured and put back on the failure path, and
the error still goes to the caller so `addKey` keeps withholding the key.

Red first: with the store refusing, the mirror held the rejected value where
the pre-save value belonged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report a route delivered only after its frame was posted (OTA phase C, C7.7 round 2)

CodeRabbit on the send path. `sendInit` answered "sent" the moment it handed
the JSON to `post`, and a rejected post was reported a turn later as a
diagnostic -- so the screen spent the one-shot `paneKey` on a frame the page
never received, cleared the native param, and the tap was gone. `publishRoute`
was fire-and-forget the same way.

Delivery is a promise now, settled after `options.post` resolves and false on
either throw or reject. Readiness stays separate: `onPageReady` fires on the
ask, as the shell's wait needs, and carries the delivery promise beside it.
The screen records the route key and calls `onRouteDelivered` only when that
promise answers true, and a refusal leaves nothing recorded so the next render
that can carry the route tries again.

Red first: with the view refusing what it was handed, the frame was built and
posted and the screen reported delivery anyway. Now it reports none while the
page's ask is still reported, and a host-level case pins the same split.

`bridge-host.ts` was at 299 of 300 lines, so the send half came out as
`bridge-host-frames.ts` rather than growing it; the file now measures 280.
The screen's own test harness never attached the view handle, so every post in
it rejected unobserved -- it attaches one now, which is what let the case see
the frame at all.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): fold two doc blocks back onto what they describe (OTA phase C, C7.7 round 2)

pullfrog's two nits, no behaviour change. `page-async-storage.ts` kept the old
`settle` block above `refusalError` when the function it described moved down
with a one-liner of its own; the orphan goes. `bridge-host.ts` had two stacked
blocks on `sendInit` after it grew a return value; they are one, and it now
says the frame is still built synchronously and only the post is awaited --
which is the property the golden recorder depends on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the host own a pending route until its frame lands

A route the page has not received is now the host's, not the screen's. `publish` keeps it
pending until a post resolves true, marks it delivered only then, and reports that through a
callback registered once per host. Movement is measured against what a frame actually reached
the page with rather than against what the host holds, so a refused frame leaves the route owed
instead of reading as one that did not move.

Three things the old shape lost, each a case here: a frame the view refused was never retried,
because only another render could try and a mounted page has none coming; a render while a post
was in flight cancelled the report the switch spends to clear the param; and a repeat tap for
the same pane was held, because the host had already moved its held route on the attempt that
failed. The retries are the moments delivery becomes possible again — the next `ready`, and a
view handle the host regains — and one frame goes out at a time.

Also folds round 4's doc nits: the stale delivery comment the screen no longer has a ref for,
and a leftover `an` in the `ready` branch.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): roll the journal mirror back when persistence fails

`writeEntries` noted the mirror before the store took it, which is what keeps an `init` built in
the same turn current — but it kept the note when the store refused. The page then received a
journal the device never wrote and resumed operations nothing was holding.

Restored on the error path, the same shape as the custom-keys save, and on both halves: the
removal that empties the journal had the same gap as the write that fills it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep publishing until the held route is the delivered one

Two halves of the same gap, both found by the bots on `fba8cb3f3d`.

A route that moved while a frame was out was held for the turn and then had nothing to wake it:
the post settling only cleared the in-flight flag, and on a mounted page no `ready`, handle or
tap need ever come along. A landing is now itself a moment to publish again, while what the host
holds is not what the page has. Only on a landing — a refused post that re-attempted itself
would spin, and that one still waits for whatever makes delivery possible again.

And the report carries the route a frame reached the page with, which the session switch was
ignoring: the older pane landing wiped the `paneKey` naming the newer one, so the page stayed
where it was and the second tap was gone. The switch now spends the param only for the pane that
was delivered.

The delivery cases render through one helper rather than six copies of the same setup, which is
what keeps the file under its cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): drop the second block describing a parameter onPageReady no longer takes

The field is documented by the block above it; this one still described the `delivered` promise
the handler was handed before the host took ownership of the pending route.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): let the page erase the route param it was handed

Ruling 34, step one: one page-to-shell frame that asks the shell to clear a one-shot route param,
naming the value the page applied.

Closed at both ends. The param is an enum of what the shell hands over, so a page cannot edit a
route it was never given; the notify name is a member of the closed union, so it gets a row in
the grant table by compilation rather than by memory, and rides no grant because it can only
spend something this shell put there. The shell declares it in `init`, the mirror of
`ready.accepts`: no shipped shell serves a page, so nothing needs negotiating today and the
page's check exists from the first version that can post one.

The comparison belongs to whoever holds the param, which is the session switch: a tap that moved
on while the page was applying the one before it leaves a newer key, and a clear naming the older
one is not for it.

`bridge-envelope.ts` went over its cap, so the page-to-shell union moved to
`bridge-notify-envelope.ts` and the fields both halves spell to `bridge-frame-fields.ts`, which
the envelope re-exports. No cap was raised.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): re-send init on a route change and track nothing else

Ruling 34, step two: the tracked handoff is gone. Deleted, not patched — the pending route, the
delivered route, the in-flight flag, the landing callback, `retryPendingRoute`, the delivery
promise `onPageReady` used to carry, and the `onRouteDelivered` that ran from the host through
the hook and the screen to the switch.

What is left is the rule in one line: `publish` sends one `init` when the route moved and the
page said it takes one, and every `ready` is answered with the route the shell holds then. A
frame the view refused is repaired by the next ask, not by a retry; the request it carried is
spent by the page.

The cases that tested the deleted mechanism go with it. The outcomes they protected are pinned
where they now live: one frame per move and none for a render that moved nothing, a lost frame
repaired by the next ask, no second `init` to a page that never said it takes one, and the
repeat tap measured through the page's erase rather than through a delivery report.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page applies a pane and erases the request that carried it

Ruling 34, step three. The page hook applies the pane an `init` names and asks the shell to erase
the param it came on, naming what it applied.

Two rules, and both are the page's because the shell has none. The erase is asked for on every
`init` that carries a pane rather than only on the one that changed something: a clear that never
reached the shell leaves the param in place, and the next frame carrying it is the repair. The
switch happens once per value: a re-asked `ready` is answered with the route the shell still
holds, and applying that again would drag the page off a tab the user has since moved to.

A repeat tap for the same pane still arrives as a request, because the erase went through in
between and the tap wrote the param back. The client refuses to post the frame to a shell that
did not declare it takes one, which is every shell older than the field.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the page bundle's readers pointed at the module that declares each name

The envelope split left three source-text readers and one import pointed at a file that now
re-exports what they read.

`shell-screen-route.ts` read the route schema back through the envelope, which reaches that file
again through the page-to-shell union: a cycle esbuild resolves to `undefined`, so every page
route mounted onto a schema that was not there yet and the browser render suite failed on twelve
files with a TypeError rather than on a build error. It reads the declaring module now.

The render harness read `BRIDGE_PROTOCOL_VERSION` and `BRIDGE_FAULT_GRANT` out of the envelope by
regex; both moved, and a regex over a re-export answers for whichever file the last split left
them in. Both point at `bridge-frame-fields.ts`, and the throw names it.

The session route's page closure is re-measured on this tree at 4,330 / 988 and the four new
modules are named, not inferred: the two halves of the split envelope, and the route-update
module and route-key reader the page-to-shell union now reaches through it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): count posted inits with the page's own reader, not a cast

The changed-code quality gate refuses a type assertion, and it is right to here: a frame the
page's reader would refuse is not an `init` the page ever saw, so a case counting them must not
count one either.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): put every mirrored write on one path that notes what the store took

Ruling 35. Fourteen call sites in six files noted the shell's mirror before persisting, and on
the page a persist can be refused: twelve left the map holding a value no store had taken, and
the next `init` handed the page exactly that. Two undid it by hand.

`persistMirrored` is the one path now, and it seats the map from what the store holds after the
write rather than from what it was handed. That is what makes the note follow acceptance without
a second opinion about it: the page's adapter resolves a `not-allowed` write and logs it, so a
rejection is not the only refusal there is, and reading back is the only answer that covers both.
The cost is one store read per mirrored write on the device, where the store refuses nothing.

`writeMirroredStorage` keeps its note-then-persist order and loses every caller but one: the
shell taking a value the page has already applied, into the device store, which has no allowlist
and no frame cap to refuse against. It builds the next `init` synchronously in the same turn, so
noting on the store's reply there would hand the page back the value it just changed. The
last-visited key moved off it, because that module is in the page's own closure.

Both rollbacks are gone with the notes that needed them, and `noteMirroredWrite` is private. A
source-scanning census holds each writer to the path by name.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): answer a page batch write at the first pair it cannot take

Ruling 35's other half. `settleBatch` collected a refusal per pair, logged each, and rejected
with the first that could reject while the rest of the batch went in anyway — one promise
describing a call where some pairs landed and some did not, which is not something a caller can
act on.

A batch is one call with one answer now: every pair before the refusal is applied, the refusal is
the answer, and nothing after it is attempted. No page-closure writer calls `multiSet` or
`multiRemove` today, so this is the rule for whoever writes the first one rather than a change to
anyone's behaviour; both directions are pinned, including the refused first pair that stops the
rest.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the mirrored write path's census and journal writer

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): count the note-first callers the mirror module says are held to one

Two gaps pullfrog found in the census. The last-visited key moved onto `persistMirrored` with no
row naming it, so removing its write path reddened nothing; and `mirrored-storage-keys.ts` says
the census holds `writeMirroredStorage` to one caller while nothing counted them.

Counted now, over every module under `mobile/src` rather than over a list of files a new caller
could sit outside of: a second one is either a writer that wants note-then-persist without the
store that earns it, or a page-reachable module that would note a refusal as an accepted write.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): fold sendInit's doc onto the function it now describes

It still described an awaited post that answered whether the page received the frame, which
ruling 34 deleted: it fires the frame and answers nothing, a refused route sends nothing at all,
and a post the view would not take is one diagnostic and no further attempt.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the page own a frame it received and could not handle

Ruling 34's addendum. On iOS the host's post is `callAsyncJavaScript`, which rejects when the
page's synchronous `onmessage` throws — with the document still mounted. The shell reads that as
a frame that never arrived, and it tracks nothing about posts, so nothing would ever send it
again. It is not a lost frame either: the page had it, one of its own listeners failed, and a
retry would fail the same way.

`receive` catches it and reports `inbound-listener-threw`, so the delivery is the channel's and
the handling is the page's. Nothing is swallowed and nothing is retried.

Two cases pinned the throw escaping and now pin it being reported: the ack that a listener bug
must not wedge, and the bootstrap stamp a tree that throws still leaves behind.

With that path closed, a post is refused only when no document holds the view, and the comments
on both halves of the route seam say so instead of naming a backoff that is stopped by then. The
repair is pinned rather than described: a tap that arrives while the view is gone is carried to
the next document's `ready`, because the held route advances on `hold` as well as on `send`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move the page's held session out of the client, which was at its cap

The listener catch put `bridge-rpc-client.ts` at 305 counted lines against a cap of 300, so this
splits rather than bumps.

The session is the one piece of the client with a lifecycle rather than a value: a second `init`
for the same session updates it in place, a different one replaces it and takes the requests and
streams of the session before it, and each case has its own listeners to fire in its own order.
The client keeps the frames and the ports; `bridge-client-shell-session.ts` keeps what they are
for, and the client's three members delegate to it.

The client measures 277 counted lines after the move. The session route's page closure is
unchanged at 4,330 / 988: the page reaches its client from the entry, not from the route module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 16:07:14 -04:00
Jinwoo Hong 8d42410e01 feat(mobile): render the HTML preview on the page in a sealed srcdoc frame (OTA phase C, C7.10 A) (#21862)
* feat(mobile): offer a cancelled top-frame navigation to the shell's opener

Both shells cancelled every navigation off their own document in silence: iOS
`decidePolicyFor` allowed only `isMainFrame && isDocumentUrl`, Android's
`shouldOverrideUrlLoading` dropped anything whose resolved path was not "/".
Nothing opened. That is the whole of ruling 29's "if they do not": a user tapping
a link inside C7.10's sealed HTML-preview frame reaches the top frame as a
navigation request, and the shell was the only thing that could act on it.

A cancelled main-frame navigation now reaches JS as `onExternalNavigation` and
goes through the same `Linking.openURL` the `externalLink` notify already uses.
The scheme list is not restated natively: the native side caps the string and
says which frame it came from, and `readBridgeExternalLinkUrl` decides what opens
in the half that ships over the air. A subframe navigation is never offered,
because that is the sealed preview loading itself.

swiftc check: OK (`checkCancelledNavigation` added, the whole suite runs).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): render the HTML preview in a sealed srcdoc frame on the page

C7.6 gave the page the artifact's source, which is the native component's Source
tab and half its job (ruling 8). Ruling 26 makes that debt: the Preview tab comes
back as an `<iframe sandbox srcdoc>` inside the page's own document.

`srcdoc` rather than a `blob:` URL, and no CSP change at all. Measured on Chromium
and WebKit: a `srcdoc` frame has no URL for `frame-src` to match and inherits its
embedder's policy instead, so it is admitted under the shipped `frame-src 'none'`,
while a `blob:` frame is refused by `frame-src` on both and refused a second time
in WebKit by the `frame-ancestors 'none'` it inherits.

Two independent fences seal it, and the render check measures each on its own:
the sandbox grants neither `allow-scripts` nor `allow-same-origin`, and the
inherited `script-src 'self'` refuses the artifact's inline script even when a
control arm grants `allow-scripts`. The inherited `img-src` and `font-src 'none'`
govern its subresources, against a no-header control where the same three are
fetched.

`allow-top-navigation-by-user-activation` is the one token granted (ruling 29), so
a tapped link becomes one top-frame navigation the shell now opens externally,
while a `<meta refresh>`, a form submit, `target="_blank"` and any script-initiated
navigation produce none.

`lucideBarrelPlugin` is exported from the bundle builder so the check builds the
toolbar's icons the way the page does rather than carrying a second shim.

config/scripts suite, this file: 14 passed, 0 errors, exit 0. Control runs: a
literal `sandbox` in the JSX reds 4, an added `allow-scripts` reds the script
fence and the token census.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the preview's sealed frame where the degradation was pinned

The three HTML-preview cases in this file described the state ruling 26 retires:
no toggle, no frame, the source only. They now pin the frame's shape through the
test renderer -- the artifact reaches it as `srcDoc`, the sandbox grants neither
`allow-scripts` nor `allow-same-origin`, both toggle positions exist, and Source
takes the frame away with it -- and the "never renders the html itself" case
becomes "never puts it anywhere but the frame", counted rather than merely absent.
What a browser does with that frame stays in the render check, which is the only
thing that can answer it.

The rich Markdown editor's half is unchanged: it is still the plain field, and
item C is a later PR.

Two mocks added: `Pressable`/`ScrollView` on the react-native double, because the
toggle renders one, and `lucide-react-native`, whose barrel imports a
`LucideProvider` its own context module does not export and so does not load under
vitest at all.

9 passed, exit 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a link-activated top-frame navigation, even to the document

F1, blocking, with F5 and F6 folded in because they are the same decision and
splitting them would mean three rewrites of one function.

F1: `<a href="/" target="_top">` and `href=""` in an artifact resolve against the
embedder's base, so both named the shell's own document URL -- which both shells
ALLOWED (iOS `isDocumentUrl`, Android's path `/`). One tap inside the sealed
preview reloaded the shell's page: bridge target cleared, load state restarted,
page state gone. A navigation a human started is now never allowed, whatever it
names; it is offered instead, and `cancelledShellNavigationTarget` drops
`orca-mobile-web:` in silence exactly as it drops `/h/other`. The page rewriting
its own path carries no gesture and is still allowed.

F5: the OFFER is gated on the same gesture, so a top-page meta refresh or a
redirect is cancelled and never opened externally.

F6: iOS returned early on `shouldPerformDownload` before the offer, so `<a
download>` was dead on iOS and opened on Android. The early return goes; a
download is refused rather than allowed when nothing started it, and a
gesture-started one reaches the opener on both platforms.

The allow half and the offer half are now one function per platform
(`MobileWebShellNavigationPolicy.verdict`, `mobileWebShellNavigationVerdict`), so
they cannot drift. The gesture is the platform's own answer: `.linkActivated` on
iOS, `request.hasGesture()` on Android.

Native tests, both platforms: document URL + gesture refused and offered; document
URL without gesture allowed; foreign + gesture cancelled and offered; foreign
without gesture cancelled and silent; download both ways; subframe never offered.
swiftc OK; control run with the gesture rule removed exits 133. Gradle
MobileWebShellDroppedNavigationTest tests=8 failures=0 errors=0.

Also corrected: the screen comment that claimed the document's own reloads reach
the handler (they never do), and the prop doc, which now states the gesture rule.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): count own-origin top-frame navigations, and drop the goto cap

F2: `page.setDefaultTimeout(4000)` capped `page.goto` at 4 s while every sibling
render check uses the 30 s default, so under load the first WebKit cases redded on
the navigation rather than on anything they assert. The cap goes; the per-action
timeouts that needed to be short are already passed at their call sites.

F1's page-side half: the rig now routes the page's own origin as well as the
foreign one and counts main-frame navigations to each separately, with two cases
pinning that `href="/"` and `href=""` each produce exactly one own-origin
top-frame request. Playwright is not the shell, so what these state is the request
the shell is handed; refusing it is the native tests' job and the docstring names
which ones. The own-origin route is registered after the initial load, because it
aborts main-frame navigations and the first `goto` is one.

The foreign-tap and meta-refresh cases now also assert zero own-origin
navigations, so a fix that merely moved the target would not pass.

16 passed, exit 0, no Errors line.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): wait for the preview frame's own load, never a clock

CI read the child frame before its srcdoc committed: frameUrl came back ''
and the control arm's script as not yet run. The frame list, the frame's URL
and anything read inside it settle at their own moments, and a 900 ms wait
reads whichever of them has happened -- on a loaded runner, none.

Polls for a child frame at about:srcdoc with its load fired, bounded by the
case's own timeout, and an override arm now resolves on the document its
srcdoc assignment commits rather than on the assignment.

Red-first: with a 2.5 s mount delay standing in for a loaded runner, the
paint case failed on both engines before this and all 16 cases pass after.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say whose violations the preview rig reads

The list is the main frame's: securitypolicyviolation does not cross into a
frame, so an empty one says the embedder raised none and says nothing about
the artifact's own style, image or font. A listener inside the frame cannot
be the fix -- the fence under test is that nothing in the artifact runs.

So the comment now claims what the reading supports, and names where the
frame's containment is actually measured: the pixel for its inline style,
the counting server for its img-src and font-src.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): announce which side of the preview toggle is showing

The Preview/Source pair carried a label each and nothing else, so which one
was showing lived only in the active background -- invisible to a screen
reader on both surfaces. Each button is now a tab carrying its selected
state, inside a tablist, and the two files' toolbars stay character-identical
so the page and the phone announce the same thing.

Red-first: the new case renders both siblings and failed on both for the
missing role before this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the WebView mock like the file's other hosts

The anti-slop gate refuses a bare `object` parameter. Takes the same shape as
the react-native mocks beside it, which pass it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): allow only the load the shell itself started

The document URL was allowed whenever the host reported no gesture, so a
navigation the shell never asked for could reload the page out from under the
session. Measured against a real WKWebView off-device: a sandboxed subframe
navigating the top frame to the document URL arrives as `.other` with no
gesture at all, and Chromium's own docs allow hasGesture() to be false for a
request a human started. Census first: nothing in the page navigates the top
frame -- no location assignment, reload, replace, window.open or form -- the
router moves by pushState and replaceState only, so the rule needs no gesture
and no page cooperation.

Both shells now raise a flag around their own load and drop it at commit, and
allow a main-frame navigation only while it is up. Everything else naming the
document is refused and never offered, since offering it would send the user
out of the app. iOS carries the second discriminator the same probe measured:
sourceFrame is the main frame for the shell's own load and the subframe for a
subframe's top navigation, so a subframe can never take the allow path.

Red-first: the Swift checks and the Kotlin tests were written first and failed
to compile against the old signature. 9 Kotlin tests, 54 in the module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): point the meta-refresh arm at the embedder's own URL

The fixture pointed off-origin, so its own-origin assertion could not move
whatever the frame did. The new arm refreshes to `/`, which resolves against
the embedder's base, and pins zero top-frame requests on a counter the
`href="/"` case proves reads 1 in the same rig.

It also counts what the frame asks for itself, with a presence control that
attributes the fence: with `allow-same-origin` and no policy the same fixture
navigates the frame to the embedder's `/`, and with the policy dropped but the
product's token kept it navigates nothing, so the opaque origin is what
refuses it rather than the CSP.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read what an action produced, not what a clock allowed

The 600 ms after every action is gone. An arm that expects a navigation now
returns the moment the route handler records it, with a deadline only so a
click that missed its target says so instead of spending the case's timeout.
An arm that expects none waits for two painted frames inside the page and one
200 ms drain for the popup queue, which is a browser-process event with no
in-page counterpart; the docstring says why that one is bounded.

Measured and reported rather than claimed: with the new wait replaced by a
no-op every arm still passes, because the reads that follow are each a round
trip. It is insurance against the runner load that produced the frame-commit
race, not a fix for a failure seen here.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the settling branch as a ternary

What oxlint's prefer-ternary asks for, and the changed-code gate with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): find the preview frame by its element, not its URL

CI timed out on all seven preview cases on one engine: the poll waited for a
child frame whose URL reads about:srcdoc, and that browser reports an empty
URL for a srcdoc frame, so every case ran to its own timeout. The same
difference had already shown as `expected '' to be 'about:srcdoc'`.

The frame is now the element: waitForSelector('iframe') then contentFrame(),
with readiness taken from the fixture's own marker inside it. Nothing compares
a frame URL any more -- the paint case reads the element's srcdoc attribute
and the absence of src instead, which is what "parsed inside the frame rather
than fetched into it" actually means. The one arm whose artifact navigates the
frame away says so rather than waiting for a marker that is not coming.

Red-first: with the old poll keyed on a URL the browser never reports, both
engines time out exactly as CI did; the new wait passes 18/18 with the 2.5 s
mount delay still injected.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): make a frame that never becomes ready say what it saw

The runner's Chrome read the preview frame's URL as empty where three
chromium builds here read about:srcdoc: bundled headless, the headless shell,
and --headless=old, all 147. So the difference is not reproducible locally and
the next CI run has to carry its own diagnosis.

The marker wait is bounded well inside the case timeout, and on expiry it
reports the frame's URL, the srcdoc attribute's length and the page's CSP
violation list -- which separates a frame the policy refused from one that was
merely slow, the two readings that look identical from a timeout.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): run the containment arms the comment only claimed

The comment said the fixture navigates nothing with the policy dropped and
the product's token kept, but no arm ran it: the control dropped both fences
at once. Both single-fence arms exist now, either of which would hold.

Measured rather than assumed, and one of them is not what the comment said.
The token alone: the navigation never starts, no request, no violation. The
policy alone, with allow-same-origin granted: the navigation does start and
frame-src refuses it, which the embedder reports as its own violation. The
engines differ only in what is left in the frame -- chromium an error page,
WebKit the artifact -- so neither is asserted; what is asserted is that the
request never reaches the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a download that names the shell's own document

The document branch skipped downloads, so `<a href="/" download>` fell
through to the offer path carrying the shell's own URL. Harmless in practice,
because the opener's scheme list drops it, but it contradicted the policy's
own comment and the prop doc, and it left the one URL that must never be
offered reaching the boundary.

The branch now covers a download too: refused, from either frame, gesture or
not, and never offered. A gesture-started download of anything else still
reaches the opener.

Red-first on both platforms: the Swift checks exited 133 and the Kotlin row
failed against the old policy. 10 navigation tests, 55 in the module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop the own-load flag wherever a document ends

The flag lived beside the load call and had to remember every ending
separately, so iOS missed two: a prop update that fails before it loads, and a
renderer that died. Both left it raised, and a navigation to the document URL
during that window would have been allowed.

It now lives in the load state machine, which every ending already runs
through -- a commit, a failure, a dead renderer, a prop update, a reset -- on
both platforms, so there is nothing left to remember. The view raises it and
reads it, and drops it nowhere.

The Android residual is stated in the policy rather than papered over: between
loadUrl raising the flag and onPageStarted dropping it, a navigation to the
document URL from inside the preview frame would be allowed, because that
callback says nothing about which frame asked and no host discriminator
exists. It needs a generation switch and a tap in that window; iOS closes the
same gap with sourceFrame.

Red-first: the new Swift row failed to compile and the Kotlin row with it.
12 load-state tests, 56 in the module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): spend the own-load flag on the allow, not on the commit

The flag stayed raised from the load until didCommit, so a second main-frame
action naming the document inside that window was allowed too and replaced the
document. WebKit can decide a second action before the first one starts, so
the commit is too late to be what spends it.

The allow itself spends it now, before the decision goes back, and every
ending still drops it for a load that is allowed and never commits.

Red-first: the new check composes the machine with the policy -- the seam the
flag and the rule meet at -- and failed to compile against the old machine.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop raising an own-load flag Android never consults

WebViewClient's javadoc, verbatim: "This callback is not called for all page
navigations. In particular, this is not called for navigations which the app
initiated with loadUrl(): this callback would not serve a purpose in this
case, because the app already knows about the navigation."

So the flag guarded nothing on this platform and, while raised, was the one
thing that could have let a competing request to the document URL through.
The view passes isShellLoad = false always now, the machine drops the field it
had no raiser for, and the policy comment carries the quote. Nothing reaching
that callback is the shell's own load, so nothing naming the document is
allowed there at all -- which also closes the generation-switch window the
residual named, so that paragraph goes.

No red to show: this is a removal, and the behaviour it leaves is the refusal
the existing rows already pin. What a device proof must check is stated in the
policy instead: a WebView that did route its own load here would have it
refused and the load state would sit at loading. 55 tests in the module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): settle every arm, not only the ones that tap

An arm with no action read its counters as soon as the frame's marker
appeared, so a zero-delay meta refresh could dispatch after the reading. The
arms that pin zero were the ones relying on it.

Every arm settles now, and what it settles on is what it expects: the sealed
refresh arms take the bounded no-navigation path, and the loose arm waits for
a recorded navigation that is neither main-frame nor foreign -- its own
frame's -- rather than the main-frame wait it would never satisfy.

Red-first: with the settling removed and the refresh moved to 2 s, the loose
arm reads 0 on both engines; with it back, 1 on both, the delay still in.
A 0.4 s refresh passes either way, which is why the finding was invisible.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): wait for what the artifact's script wrote, not for the element

The two-fences control asserts the inline script ran, and the marker element
it waited for exists from parse time, so the arm could read window.__ran
before the script had touched it. Under a loaded runner that reads 0, which is
CI's "expected +0 to be 1" on chromium.

Readiness is now per-arm: 'script' waits for the script's own write, 'load'
for the arm whose artifact navigates the frame away, 'artifact' for the rest.

Red-first: with the inline script's write delayed 1.5 s, the old arm fails on
both engines with that exact message and the new one passes, delay still in.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): bound the rig's waits by the case timeout and nothing else

Two inner deadlines, 20 s and 15 s, were racing the outer one they sit
inside, so a slow runner could fail a case on a number this file picked
rather than on the one the case declares.

Both now run to vitest's own `ctx.signal`, which aborts when the case times
out. On abort the rig prints its reading -- the frame's URL, the srcdoc
length, the violation list, or the navigations it did record -- and lets the
case fail as the timeout it is. Nothing is rethrown from that path: a
rejection raised after vitest has given up on a case has nobody left to catch
it, and an unhandled one fails a run whose every test passed.

Red-first: with the marker selector pointed at an element that never appears
and the case timeout cut to 8 s, the diagnostic prints and the case fails as
`Test timed out in 8000ms` rather than hanging in silence.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): ask a stuck preview frame everything it can still answer

The old diagnostic said only that a frame never parsed, and its violation
list was the top document's -- securitypolicyviolation does not cross frames,
so it said nothing about what the frame itself refused.

It now prints the browser version, the arm it came from, the iframe element's
srcdoc length and sandbox, contentDocument.readyState and contentWindow.href
(which answer for a same-origin arm and report `refused` for an opaque one,
so the arm's own origin is in the log), and every Playwright frame with its
url, name, readyState, body length, marker presence, window.__ran and its own
violations. Per frame, because the page's init script installs the collector
in every frame -- measured on both engines -- and CDP evaluates inside an
opaque frame whose scripts are blocked.

Two corrections that the local probes forced. The reading is sampled while
waiting and printed from the last sample: read at the abort it lost its race
with vitest's teardown and printed nothing at all. And two arms had never been
given the case's signal, so their waits could not be bounded or diagnosed.

The diagnosis moves to its own module because the test file is at its line
limit, and because the bound and the reading it prints are one thing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): build a widened control frame instead of relaxing a live one

A live frame cannot be relaxed. Sandbox flags are fixed on a browsing context
when it is created, and Chrome 152 keeps the original ones through a srcdoc
reassignment while still parsing the new document -- so the control arms that
widened the product's own frame stayed sealed on the runner, and CI read a
script that never ran and a refresh that never navigated. Chromium 147 here
honours the relaxation, which is why it passed locally for a year of runs.

The override now clones the element, sets the sandbox on the clone, gives it
the artifact and replaces the product's frame with it, so the widened flags
are there from creation -- the way the product does it, since React sets the
attribute before insertion and never after. The product's own arms are
untouched: a null override still returns immediately.

And the control can no longer pass for the wrong reason on any engine. The
header-keeping arm now reads the violation raised inside the frame: a
script-src refusal can only happen if the sandbox let the script start, so it
separates "the policy held" from "the frame was never widened", which the old
arm could not. The loose arm pins an empty list beside it, the sealed arm pins
an empty one too, and those three readings are the whole fence story. The
violations come from each frame's own collector, because the embedder never
sees them.

Two diagnostic repairs the local probes forced: the browser version is read
once at open, since asking at the abort printed "browser unknown" in the CI
log this exists for, and the reading is sampled immediately as well as every
five seconds, since a wait that only prints "no reading was taken" says
nothing.

Red-first: with the widening disabled, both engines fail exactly as CI did --
180 s timeouts on the script arm -- and the diagnostic names the arm, the
version and the sandbox it actually had.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): put the toggle's selected state where a browser reads it

CodeRabbit is right, and the browser says so: react-native-web's createDOMProps
never reads accessibilityState, so on the page the tab pair emitted role="tab"
and no aria-selected at all. The test renderer could not see it, because it
reports the props the component was handed rather than the DOM they become.

Both siblings carry aria-selected beside accessibilityState now -- the phone's
screen reader takes the latter, the browser the former -- and the toolbars stay
character-identical.

Red-first, in a real browser on both engines: the rig now reads every
[role="tab"] element's aria-selected before and after the tap, and it read null
for both positions before this line existed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 03:32:34 -04:00
Jinwoo Hong 5d13a70ea3 fix(mobile): keep an in-page hop local only when the session's grants cover it (OTA phase C, C2.9) (#21723)
* feat(mobile): carry what each page route declared in init (OTA phase C, C2.9)

The page decides an in-page hop from `init.pageRoutes`, which says which patterns
this shell would render and nothing about what each one costs. So a push kept
local on the strength of the pattern alone runs the target under the opener's
grants — which is how the tasks page is reached from the wide-layout sidebar
without `native.clipboard.write`, and why its copy actions refuse silently.

`init` now also carries `pageRouteGrants`, the manifest's own route/grant pairs,
from the manifest the shell already holds. Optional in both directions: an older
shell omits it and an older page ignores it, and a page that receives none keeps
today's rule. No new frame kind, no cap change, no protocol bump.

The grammar is the manifest's, imported rather than restated
(`MobileWebBundleGrantNameSchema`, now exported for this), so a grant name the
bundle could not have declared cannot reach the page through this field either.
The host validates the pairs before it builds the frame and refuses the session
when they fail, for the reason it already refuses a malformed route: an `init`
the page would reject whole is worse than no session at all.

Two files were at their line ceiling and are split rather than bumped. The pairs
schema moves to `bridge-page-route-grants.ts`, which is read by both the envelope
and the host, so it belonged in one place anyway. In the session reducer the
three sites that each spelled out "patterns, their grants, this route's grants"
become one `routeViewOf`; that is a net reduction and removes the fourth spelling
before it is written.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a hop local only when the session's grants cover it

The rule the page was using is "the shell would render this pattern", and that is
not the question. Grants are resolved once, from the route the shell opened, so a
push kept local runs the target under the opener's list. On a wide layout the
sidebar renders beside every `/h` route and pushes `/h/<id>/tasks` through this
seam, so from the worktree list, agent history or the files pages the tasks page
ran without `native.clipboard.write` and its copy actions refused with nothing on
screen to say why.

`servedHere` now means served here *and* covered: the target's declared grants
must be a subset of this session's. An uncovered page route is handed to the
shell exactly like a non-page route, and the shell opens it as its own session
with its own grants — which is the mechanism that already exists, rather than a
new one.

Three answers, not two, because an absent field is not an empty one. A shell that
sent no pairs keeps the old behaviour: `null` is "nobody told me", and an older
shell has to keep working. A target the shell lists but names no entry for is
*not* covered — the page cannot justify that hop, so it hands it over rather than
guessing in the direction that loses grants.

This is C3.1's explorer ⊇ preview finding without its pairwise pin: that hop is
covered by this rule and stays local, and the rule scales to the sidebar, which
reaches every route and which no pairwise list can keep up with.

Red first on the two cases only the new rule answers; the other four are the
regression guards and passed before and after. Two whole-session assertions
gained `pageRouteGrants: null`, which is what the reader now returns.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the sidebar hop in a browser, under the session's own grants

The unit tests pin the decision; only a browser shows the control exists, is
reachable at the viewport where the sidebar renders, and that the document does
not move when the hop is handed over.

Four cases on the shared harness, which now forwards `pageRouteGrants` (omitted
when a caller names none, because an absent field is not an empty one and the
page reads the difference).

- Wide, session without `native.clipboard.write`: tapping Tasks posts exactly one
  `navigate` notify, the document stays on the worktree list, and **no new chunk
  is fetched** — which is what says the page did not quietly render tasks under
  the wrong grants.
- Wide, same tap with the grant added: no notify, the document moves to `/tasks`.
  Without this the first case would pass on a page that simply never navigates.
- Wide, shell sending no pairs at all: the old behaviour, local. An older shell
  must not start handing every hop over on a field nobody sent.
- Narrow: asserts the absence rather than a tap. `app/h/_layout.tsx` renders the
  sidebar only on a wide layout, and only that header branch labels its Accounts
  and Tasks controls; the narrow header's are unlabelled pressables. So the hop
  does not exist at that viewport, and `getByLabel('Tasks')` finding nothing is
  the honest assertion. That unlabelled narrow header is a real accessibility gap
  and is not this lane's to fix.

Registered in `pr.yml`'s `mobile_web_app` job beside the other render checks.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census the in-page hops a session's grants cannot cover

The rule landed in the commit before this one decides each hop; this says which
hops those are, so a route's grants growing — or a new push between two page
routes — shows up here rather than as a verb that silently refuses on a device.

Openers are every page route, not the one that happens to push. On a wide layout
`app/h/_layout.tsx` renders the worktree-list sidebar beside every `/h` route and
its header pushes tasks, which is exactly why a pairwise pin is the wrong shape:
the sidebar reaches everything, so the census has to be the cross product of what
the manifest declares against what the source actually builds.

Targets come from the hrefs the app builds, read out of `mobile/src` and
`mobile/app` and reduced to route patterns, so a hop nobody writes is not pinned
and a hop someone adds is. A presence case asserts the sidebar's tasks push is
among them, because a census that stopped finding hops would go quietly green.

Two hops are pinned as handed off today, both into tasks, which is the only route
declaring more than `navigate` and `storage`. A third case asserts the other half
of the rule on the manifest: a target asking for no more than its opener stays in
the document.

Checked that it discriminates rather than assuming: widening the worktree list's
grants to cover tasks fails the pin, and restoring them passes it.

**No pin was deleted.** The brief expected C3.1's pairwise explorer/preview pin to
be replaced here, but C3.1 is not on this base — `MOBILE_WEB_PAGE_ROUTES` has
three routes and no `files` entry, so there is nothing to remove. When C3.1 lands,
its pin is this census's to subsume.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop an unused import from the hop census

`statSync` was imported and never used; `oxlint` fails it. My error: I committed
the census on a green test run without waiting for lint, the same order mistake I
made earlier in this lane. Fixed forward rather than amended, because the lane
forbids rewriting a commit that exists.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): fold C3.1's pairwise grant pin into the hop census

C3.1 landed while this branch was open, and it brought the case this lane
generalises: the explorer pushes to its own preview, that push stays in the
document, so the preview runs under the explorer's grants. Its pin asserted that
one pair by name.

The census now covers it as a consequence rather than a rule. With the files
routes in the manifest the cross product finds six more hops the session cannot
cover — the sidebar into files from the worktree list and from agent history, and
both files routes into tasks — and it does **not** find explorer → preview,
because the preview declares no more than the explorer. That absence is the
pairwise pin, derived.

So the pairwise block is deleted, with its import. The rest of that file stays:
its external-link seam checks and its clipboard-absence control are about what
the files closure contains, which this census says nothing about.

Checked the extended census still discriminates: granting the explorer
`native.clipboard.write` fails the pin, restoring it passes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the sidebar hop from a files route, not only the worktree list

The defect is not "the worktree list pushes tasks". On a wide layout the sidebar
renders beside every `/h` route, so the same hop exists from the files explorer,
whose session carries `externalLink` but not `native.clipboard.write`. One opener
proving the rule would have left the general case to inference, which is the
inference C3.1's pairwise pin already made once.

Opened on `/h/<id>/files/<wt>` with the files route's own grants, the sidebar's
Tasks control posts exactly one `navigate` notify, the document stays on the
files route, and no new chunk is fetched.

The harness helper now takes the route and the text to wait for, so a case can
open on something other than the worktree list without a second copy of it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the render helper wait on the text its caller named

The `awaitText` parameter I added in the commit before this one was never wired
into the wait, so it was dead and `oxlint` failed it. The case still passed,
because the files route renders the host name in its sidebar and that is what the
helper was still waiting on — which is exactly the kind of accident a dead
parameter hides.

Third time in this lane I have committed on a green test run before lint
finished. Fixed forward, not amended.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): carry route grants through the download path

`onManifestRead`'s download branch set `pageRoutes` and `routeGrants` from the
new manifest and dropped `pageRouteGrants`; nothing downstream recomputes it, so
every first install and every OTA update reached `ready` with the default or the
previous generation's pairs. The page then read each target as listed-with-no-
entry and handed off every in-page hop.

`routeViewOf` moves to `page-route-policy.ts`, beside the two functions it calls,
to keep the reducer under its line cap without a bump; its stale neighbouring
comment, which described a filter that moved into it, goes.

Red first: the cold-cache and generation-change cases failed, the cached-hit case
already passed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): derive census targets from navigation call sites

The reachability filter was inert. Harvesting every `/h/${…}` template caught the
five screens that declare their own mount pathname, two `pathname ===`
comparisons and the route template types, so every declared route was reachable
through its own mount: the pinned table was the all-pairs one, eight hops with
the filter and eight without.

Targets now come from the arguments of `router`/`navigation` `push`, `replace`
and `navigate`, and of `navigateFromHostList`; mounts, comparisons and types are
excluded by construction because they are not navigation arguments. Two real
hops are not written as a literal, so a local binding or a call is followed one
step to the function that returns the pathname: the files explorer is pushed as
`{ pathname: descriptor.pathname }` and the preview as
`push(createMobileFilePreviewHref(...))`. A call site whose target cannot be read
is returned rather than dropped.

Derived patterns go from 11 to 10; the pinned table stays at eight because all
five page routes are genuinely pushed to. What changes is that the filter now
discriminates: deleting the header's two tasks pushes reds the presence case and
drops the four `-> tasks` rows from the pin, where the old derivation stayed
green on the same deletion because `app/h/[hostId]/tasks.tsx` still declared the
pathname. A push added at a real call site appears in the set.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): restore the preview-declares-something guard

The pairwise pin this case replaced asserted the preview declares at least one
grant before asserting the explorer covers them all; without it two empty lists
satisfy the subset check and a route that lost its grants passes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): describe the route list under the handoff rule

Two passages described the world before this PR: the explorer's note said the
census pins its pair with the preview, and a closing paragraph left the sidebar's
tasks hop open for a later PR. This is that PR. Covering the preview now buys the
in-document hop rather than making it correct, an uncovered target is handed to
the shell and reopened under its own grants, and the census reads the explorer to
preview relation off this list rather than pinning it by name.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): mirror the manifest's tasks grants in both fixtures

CodeRabbit on #21723: both fixtures declared the tasks route as `navigate`,
`storage`, `native.clipboard.write` while the manifest also declares
`externalLink`, so no covered-session case ever required it.

Both now mirror the manifest's four, and the covered sessions hold them. That
alone does not make an `externalLink`-blind rule fail, since those sessions hold
every grant either way, so the unit suite gains the case that does: a session
holding the clipboard but not `externalLink` must still hand the hop off.
Mutating the rule to treat `externalLink` as always held reds that one case and
no other.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): make a stalled hop name its own cause

Both waits for the hop to land read as a bare 30 s timeout when it does not. The
CI failure that sent this file back was a `TypeError` inside React Navigation
that blanked the document, and it was invisible here because the error
assertions run after a wait that never returns.

The wait now throws with the page's own account: the pathname it stayed on, the
collected page and console errors, the `navigate` notifies posted, the first 300
characters of the body, and every `.js` response since the click with its status.
The response listener records every script answer rather than only the 200s, so a
chunk the navigation waits on can be seen failing; the 200-only list the
no-new-chunk assertions read is unchanged, as is everything the five cases
assert. Kept in this file because no other render file waits on the pathname
moving.

Proved by mutating the rule to hand every hop off: the covered case fails naming
the pathname it stayed on, an empty error list, the notify it posted and no
scripts since the click — which is the handoff signature, distinct from the
crash signature CI saw.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): aim the narrow hop at the control C2.10 named

The narrow case asserted the absence of a labelled Tasks control, which
was true only because the narrow toolbar carried no accessibility props.
C2.10 gave it the wide sibling's role and label, so the assertion was
red on the merge and, worse, the rule this file is about went unproven
on the branch the phone actually presses.

It taps that control now: at 390 px there is exactly one, and the tap
posts exactly one navigate notify for the tasks route while the document
stays on the worktree list and fetches no new chunk. Red first against
the merged header (count 1, expected 0); with the session given
native.clipboard.write the hop goes local and the case reds, which is
what says the assertions discriminate.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): drop the handoff predicate's contradicted one-liner

The pre-C2.9 summary said the answer is whether this document renders
the target, which is exactly the claim the block comment below it
replaced: the predicate now also requires the target's grants to be
covered. Two doc comments on one declaration, the first of them wrong.

Comment only; the 35 handoff cases are unchanged and green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert which field a route refusal blames

The host builds `pageRouteGrants: <issue>` so a refusal says which of the
two checked inputs failed, and nothing read it: the case counted
refusals, so a host that reported the route's own verdict for a malformed
pair would have stayed green while sending whoever reads the refusal to a
pathname that was never the problem.

The case pins the prefix, a non-empty issue behind it, and that the
diagnostic and the callback carry the same string. The control is an
opener that fails the other way: a malformed route reports its own issue
and does not take this prefix, without which the pin would hold on any
reason at all.

Red first with the field branch dropped from the reason: the prefix
assertion fails and the control stays green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): stop exporting the route filter the reducer stopped calling

`implementedPageRouteEntries` and `implementedPageRoutes` were the
reducer's two ways in before it moved to `routeViewOf`. The entries form
had no caller anywhere afterwards and the patterns form had only this
test, so the module's public surface advertised two functions no product
code reaches. Both are module-local now; the surface is
`matchesRoutePattern`, `pageRendersRoute`, `grantsForRoute`,
`routeViewOf` and the grant list.

The test reads the same list through `routeViewOf(...).pageRoutes`, which
is the reducer's own view of it, so no assertion changed and no export is
kept for a test.

Red first: with both un-exported and the test untouched, seven cases fail
with `implementedPageRoutes is not a function`; routed through the view
all nineteen pass. Still discriminating, as a control: with the grant
filter dropped from the entries helper, four of them fail.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): route the merged haptics cases through the policy view

PR E's two haptics cases arrived with the merge calling
`implementedPageRoutes`, which this branch had already made module-local,
so the merged file was red with `implementedPageRoutes is not defined`
on both of them. They read the same list through `pageRoutesOf`, the view
the rest of the file already uses, so neither assertion changes.

PR E's paragraph named that function for the filter it describes; the
filter now sits in the entries helper the view is built on, so the
sentence says that instead of naming a function the reader cannot see.

Red: the two cases above on the merge. Green: all 21, PR E's two included.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): mirror the haptics token in every handoff fixture

PR E put `haptics` on all five manifest routes, and these fixtures still
carried the pre-E grant lists: tasks with four grants where the manifest
now declares five. A fixture that is short the same token on both sides
of the subset check agrees with the rule by accident, and would have gone
on agreeing after the token stopped being universal.

The pairs mirror the manifest now, and each session carries what its
opener route would actually be granted, since the host narrows a route's
declared grants to what the shell implements and the shell implements the
token.

Red first, with the token added to the pairs alone: the two covered-hop
cases flip to handed-off, `stays in this document when the session
already covers the target` and `keeps the hop in the document when the
session covers tasks`. Green once the sessions carry it, 35 and 5.

The hop census needed nothing: it reads `MOBILE_WEB_PAGE_ROUTES` itself.
Measured there, all 5 routes declare the token and it is the missing
grant in 0 of the 8 uncovered pairs, so it cannot decide a hop and the
rule still reads only `pageRouteGrants`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): count C2.9's two bridge modules in the session route closure

#21908 recorded this pin at 4,324 for the haptics notify module. C2.9
adds two more that the same closure reaches: the page-route-grants schema
and the manifest contract whose grant grammar it imports rather than
restates, both pulled in by `bridge-envelope.ts`, which the page reads to
parse `init`.

Named in the docstring beside #21908's sentence rather than folded into
its number, because the three modules arrived from two PRs and a single
count with one reason invites the next author to assume the rest.

Red first against 4,324: expected 4,326. Measured on this head, not
inferred -- a control worktree at pristine main gives 4,324, so the two
are this branch's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 01:29:02 -04:00
Jinwoo Hong c77a82f783 feat(mobile): fire the page's haptics over the bridge notify (OTA phase C, C7.10 E) (#21864)
* feat(mobile): give the page a haptics notify and the grant that gates it

`native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly
the five `src/platform/haptics.ts` has, and the single token `haptics` joins
`BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb
because nothing is owed back: a reply would spend a slot in the same 64-deep
in-flight window a forwarded request does, and there are 90 call sites in this app,
some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30).

The arm's fields live in their own module because `bridge-envelope.ts` is at its
line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays
in the envelope, so the fields are spread in beside it rather than reading it back
through an import cycle.

The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other
device-local notify does: the host is the protocol's side of the bridge and a static
import of the app's haptics would put `react-native` and `expo-haptics` in its
graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping —
`haptics.ts`'s own functions, its `Platform.OS` split and its Android
`HapticFeedbackConstants` untouched.

The dispatch branch rides along with the union rather than waiting for the page
side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over
that union, so the shell does not compile without it. That is the totality working,
and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is.

Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin
and the missing-row type error all failed against the tree before this. Control on
the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): post the page's haptics over the notify instead of doing nothing

`haptics.web.ts` stops being five no-ops. Each of the five posts its own kind
through the notify seam the entry publishes — the same shape
`publishExternalLinkOpener` has, and for the same reason: every caller is a plain
function inside a row's press handler that no provider wraps. `notifyHaptics` joins
the page client beside the other gated notifies and answers whether the frame left,
which nothing reads: a tap that did not buzz is what the page did before this, and a
warning per refusal would be one per row of a scrolling list.

Measured off the frame the client posted rather than a written copy of its shape,
which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection /
success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`,
and a twelve-row scroll 888 bytes across twelve frames.

The `web-overrides.json` reason now says what the file does instead of what it
declines to do.

Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a
function`, and the six client cases on `notifyHaptics is not a function`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): grant haptics on every page route, with a census that derives the list

All five declared page routes carry the `haptics` grant, and the list is a
measurement rather than a hand choice: `WorktreeListRow` is in every page closure and
calls the seam, so a route without the grant is a page whose taps stop buzzing with
nothing on screen to say why. Grants are resolved once from the route the shell
opened and held for the session, so the declaration is the only place to fix it.

`mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one:
it reads the kinds off the tuple that declares them, finds every exported `trigger…`
function in a haptics module, and reports the kind each one posts. The posting call is
found through the binding `publishHapticsNotifier` assigns rather than a local spelled
`post`, because a rename would otherwise turn every posting site into a non-posting
one and leave this green on a page with no haptics at all.

The census proper holds each route's closure to the `.web.ts` sibling, asserts at
least one importer so the grant is not idle, and derives the granted-route list from
the closures. The control is the design's: the same walk over the native sibling
finds the same five functions and no posting site, so "all five post" is a number
rather than an empty scan.

Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post
reds 1 of 23.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record what the haptics notify costs a page closure

One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it
arrives through `page-route-policy.ts` reading the grant token rather than through the
seam, whose import of the kind type is erased; its only dependency is `zod`, which the
envelope already put in every closure, so the module total moved by the same one.
Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336.

Pinned structurally rather than as a total, because an absolute closure count is
main's to move and a number that drifts for unrelated reasons is one nobody reads.

The call sites this replaces, measured over product modules: `triggerError` 43,
`triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10,
`triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus
`page-haptics.ts` itself.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): carry the haptics grant into the shell's two grant pins

`bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in
that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it
already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over
and reaching the real module pulls in an Expo runtime this test does not have, which
failed the whole suite at import. Which expo member each kind reaches stays in
`page-haptics.test.ts`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): map each haptic kind to a named import, not a namespace index

The changed-code gate refuses a computed reference into an imported namespace, in
both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()`
is a call nothing can follow. Each function is a named import instead, which also
keeps the second compile-time direction — a row naming something `haptics.ts` does
not export is now an import error rather than a `keyof` mismatch.

The third direction moves with it, from a namespace read in the test to the census
that already reads both files' text: `hapticsImportedNames` names what the shell's
mapping takes from the app's haptics, and the census holds that to the five the
native file exports. So a haptic added there with no kind of its own still fails,
and now it fails where the other two siblings' names are already compared.

The test's two `as` assertions become one annotated hoisted type, the shape
`MobileWebShellScreen.test.tsx` uses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the true reason the haptics grant is one token

`GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>`
with lowercase segments, which is why it rejected `native.media.readChunk` and
rejects `navigate-back`, not a dotted name as such. So four comments claiming a route
declaring the notify's own name would have its bundle refused were false, and they
are gone: the grant is a token because the notify table's grants are tokens, a notify
not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from
the verb table alone.

Also folded, with the false claim: `implementedPageRoutes` filters on
`grants.every(implementsGrant)`, so a token every page route declares couples the
whole set to a shell that carries it — against one without it, no page route is
served at all and the phone renders five native screens. Stated in the function's
docstring and beside the census's derived list, and pinned: the same declaration
under a grant this build does not implement comes back empty, with the token-free
route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS`
reds that case.

`%#` consumes no argument, so the web seam's five cases were titled with the whole
function body; the kind is the first element now and `%s` names it. One 110-char
comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two
still over it there are main's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 00:29:52 -04:00
Jinwoo Hong e5181113ca feat(mobile): add the media verbs the page's pickers and paste will use (OTA phase C, C7.4) (#21779)
* feat(mobile): add the media verb rows to the shell's native table (OTA phase C, C7.4)

`native.media.pick`, `native.media.readChunk` and `native.media.release` join
`BRIDGE_NATIVE_VERB_NAMES`, so each becomes a grant name `init.grants.native` can
carry and each gets a strict zod contract in `bridge-media-verbs.ts`. A chunk read
is held to the upload path's own budget, imported rather than restated: the leaf
module `mobile-clipboard-image-upload-chunk.ts` now declares
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` so the bridge can read it
without pulling the upload path's RPC operations into every page closure.

Inert: no page calls the verbs and no route declares them, so an older page never
names them and an older shell refuses them as `unknown-verb`.

`native.clipboard.read`'s `{ mime: 'image' }` refusal is retired rather than left
pointing at nothing. The broad shape existed so a later build could serve an image
without a contract change; that build is `pick { source: 'clipboard' }`, which
stages the image behind a handle instead of inlining 24 MiB of base64 through an
8 MiB reply. The mime enum is text only, the handler's out-of-scope arm is gone,
and the refusal's test flips to the new answer: `invalid-params`, before dispatch.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the shell the staged-media handle registry (OTA phase C, C7.4)

A handle names a file this shell copied into its own cache, and such a file has no
owner otherwise: the page that asked for it is a document that can navigate, fault
or be swiped away without telling anyone. So the lifetime is bounded four ways and
all four are here — the page's own `release`, `MEDIA_HANDLE_TTL_MS`, and
`releaseAll`, which both the session's end and the page's unmount reach through
`useMediaHandleRegistry`. The TTL is measured from the last touch, not the mint, so
a page reading a large file one chunk at a time cannot have it swept out from under
it; its five minutes is sized for the page that picked and then stopped, not for
the read, which is 48 round trips on the largest item a pick may stage.

`BRIDGE_MEDIA_MAX_LIVE_HANDLES` caps what one session holds. A pick that would pass
it is refused whole and discards what it staged: half a multi-select is an answer a
page cannot tell from a user who picked fewer.

Three refusals join the seam's vocabulary. `native_media_handle_unknown` covers
never-minted, released and swept alike — which of the three it was is a fact about
another page's pick. `native_media_range` refuses a read at or past the end of an
item that had bytes, because the previous chunk already said `eof` and an empty
answer would let a page loop instead of failing where the bug is.
`native_media_handle_cap` is the cap above.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): serve the media verbs on the device, behind staged handles (OTA phase C, C7.4)

`pick` runs the OS permission prompt inside the shell — `expo-image-picker` for the
library, `expo-document-picker` for Files, the pasteboard for `clipboard` — stages
each item as a file this shell owns, and answers handles. `readChunk` reads the
byte range the registry hands it and base64-encodes it through the upload path's
own accumulator; `release` deletes the file.

The device calls live in `native-media-device.ts` and nothing else. Importing
`expo-image-picker` imports React Native, so a module naming it cannot be driven in
a unit test at all, and the arms worth pinning are exactly the ones a simulator
makes expensive: a denied permission, a cancel, an item over the staging ceiling.
`native-media.ts` takes those seven calls as dependencies and is tested whole.

Two refusals join the vocabulary, each because a page acts differently on it.
`native_media_permission_denied` is a permission the user can still grant in
Settings, not a library the shell could not read. `native_media_too_large` is an
item over `MEDIA_STAGED_MAX_BYTES`, weighed from the staged file rather than from
what the picker declared, since a picker's own size is optional on both platforms.
A pick refused that way discards every file it staged.

Each chunk is base64 on its own, so a page concatenates decoded bytes and never
strings: only the last chunk of a read ends on a partial group.

Measured: the largest reply this verb can produce is a full
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` chunk, 524,427 bytes on the
wire with its envelope — one frame, 80.0% of the 640 KiB frame cap and 6.3% of the
8 MiB reply ceiling. Pinned in `native-media.test.ts`, so a cap or an envelope
field that moves shows up as a diff.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): only mint a media handle over a file this shell owns (OTA phase C, C7.4)

The Android arm of the same handler. Both pickers are configured to hand back a
copy in this app's own cache — `expo-image-picker` always copies, and
`expo-document-picker` does under `copyToCacheDirectory` — and the whole handle
contract rests on it: `release` is a delete, and so is the TTL sweep. A provider
that answered `content://media/...` instead would mint a handle over a file this
shell can neither size nor delete, and every sweep would be a silent no-op that
leaves the cache growing. `ownsStagedMediaUri` refuses that where the assumption
is made rather than letting it surface as a cache that never empties.

`native-media-device.test.ts` pins the options that make the assumption true: the
document picker's `copyToCacheDirectory`, the library picker's `base64: false` and
its selection limit, the pasteboard's png, and the cache file `stageBase64` writes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): move a picked image across the real pair, chunk by chunk (OTA phase C, C7.4)

Through `createFakeBridgePortPair` rather than the host alone, because a chunk is
the one reply this seam produces that is within a rounding error of the frame cap:
a case that called the server directly would never serialize one, and serializing
is where a reply that fits and a reply that splits part ways.

A granted page picks an 18 MiB item, reads all 48 chunks in order to `eof` and
releases it; the decoded length is the file's own and both ends are the fixture's,
which a reader that dropped or reordered a chunk would not have. Nothing reaches
the desktop client. A page granted only `navigate` and `storage` is refused
`native_verb_ungranted` with no picker run — the refusal is the host's, because
the page side of these verbs is C7.6's. A released handle, a swept handle, a read
past the end and a pick over the live-handle cap each come back under their own
code.

The pair's default verb handler answered a clipboard shape for every verb, so a
media call through an unconfigured pair came back as `native_verb_result` — a
shell bug's code for a harness that was never told about the verb. It now answers
one shape per row of the table.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the four type assertions the changed-code gate found (OTA phase C, C7.4)

`new Array<T>()` for the two hoisted mock ledgers, and the probe holds its handler
in a record it null-checks rather than asserting one that a render might not have
produced.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): rename the chunk verb to native.media.read, which a manifest may name (OTA phase C, C7.4)

`native.media.readChunk` is not a legal grant name. `GRANT_NAME_PATTERN` holds every
`native.` segment to `[a-z][a-z0-9]*`, so a route declaring it fails
`MobileWebBundleRouteSchema` — and `bundled-mobile-web-bundle.ts` parses the manifest
whole, so the camel-cased segment is not a route that degrades to its native screen.
It is a bundle the phone rejects entire. Params are unchanged.

The test that would have caught it now reads the schema itself rather than a copy of
its pattern: every entry of `MOBILE_WEB_SHELL_GRANTS`, and every name in
`BRIDGE_NATIVE_VERB_NAMES`, parsed as the grants of a manifest route. Both lists,
because the spread is what makes them agree today and a build that stopped spreading
would leave this the only thing that noticed. Red on the old name with the exact
message: `native.media.readChunk: expected false to be true`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): exercise a short read, which nothing separated from the chunk cap (OTA phase C, C7.4)

Every other read in the suite asks for a whole chunk of a file shorter than one, so
a handler that ignored `length` and read to the end passed all of them: the fake
file clamps at its own size. Replacing the range with the cap left 74/74 green.

A 16-byte read at offset 400 of a 1000-byte item now pins the length, the offset and
`eof: false`. Under that same mutation it reds with `expected 600 to be 16`, and it
is the only case that does.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): kill the four device arms that survived mutation (OTA phase C, C7.4)

Each was green with the arm removed; each now reds with it removed and nothing else
does.

The data-url strip: `getImageAsync` answers `data:image/png;base64,...`, which is
what an `<Image>` source wants and not what a file wants. Staged unstripped, every
byte is shifted by the prefix and the page decodes a corrupt image with no error
anywhere. The old fixture had no prefix at all.

The cancel flag: both pickers answer `assets: null` beside `canceled: true` today,
so a handler keyed on the list alone passed every fixture here. The new case sends
the flag with a populated list, which is what a picker version that changed its mind
would send.

The unknown mime: an empty string is not a mime the result schema takes, so the
alternative to the floor is `native_verb_result` — a shell bug's code for a document
picker doing what it may do.

The handle close: a ledger per opened handle, asserted on the way out and on the way
through a reader that throws. A file handle a shell leaks is invisible on a fake and
a file descriptor on a phone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the stated constants as literals (OTA phase C, C7.4)

The TTL, the live-handle cap, the handle and mime lengths, and the 48 chunks an
18 MiB item reads in. Literals, not the constants restated: every one is a number a
body claims and a reviewer checked, and read through its own name the assertion
would hold whatever it became — which is what the 524,427-byte reply pin already
does for the frame it measures.

Each reds when its number moves: 8 to 12, 64 to 32, 128 to 256, five minutes to ten.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name the fixture by its size, assert the clipboard refusal, reach the clamp (OTA phase C, C7.4)

Three minors from round 1.

The case that said "18 MiB" staged a million bytes. Renamed to what it stages, with
a line saying the largest item a pick may hold runs in the port-pair suite, where
the frames are serialized and the size is the one that matters.

The clipboard image case had been weakened to a bare `.rejects.toThrow()`, which
would pass for a handler that reached the pasteboard and failed there — the one
outcome it exists to rule out. It now asserts a `ZodError` naming the `mime` path,
and reds when the parse is taken out.

The registry's chunk clamp was unreachable behind the params schema. Kept and
reached rather than deleted: the two bounds are different promises, one saying what
a page may ask for and one saying what the registry will hand any reader, and
`read` is a public method its own suite already calls directly. A case asks for
three chunks at once and gets one; it reds when the clamp goes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): copy a provider uri into the cache instead of refusing the pick (OTA phase C, C7.4)

`MediaHandler.readExtras` in expo-image-picker 55.0.24 has a reachable arm: when
`toMediaType` cannot resolve a MIME it answers
`ImagePickerAsset(type = null, uri = uri.toString())`, the provider's own
`content://` uri, uncopied. The OS completed that pick, so refusing it as
`native_verb_failed` loses a photo the user chose — and the comment above the guard
claimed both pickers "always" answer `file:`, which the same file disproves.

The pick now copies what it does not own into the shell's cache and mints the handle
over the copy, which is what the body already claimed. The guard stays for anything
the copy could not adopt, and that arm still fails closed: a sweep that can never
delete anything must not look like one that did. The failure path discards what this
shell owns rather than the uris the picker answered, since a provider's uri was
never ours to unlink.

Copied through `bytes()` rather than `copy()`: `FileSystemPath.copy` goes to
`javaFile.copyRecursively`, a `java.io.File` with nothing to open for a provider uri,
while the read path goes through the unified file and does. A source that cannot be
read takes the empty destination with it, because the caller never learns that name.

Red-first: a fixture answering `content://media/external/images/media/42` asserting a
handle over a `file:` copy, its 300 bytes read back whole, and the copy being what
`release` deletes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound a multi-select to the room the registry has left (OTA phase C, C7.4)

`selectionLimit: 0` means unlimited to the OS picker, and `mint` then refuses the
whole pick past `BRIDGE_MEDIA_MAX_LIVE_HANDLES`. A user who chose nine photos waited
through the OS copying every one of them into the cache to be told none were taken.

`pick` now reads `remainingCapacity()` before it launches anything: an empty room is
`native_media_handle_cap` up front, for every source, with no picker run and no byte
copied. What room is left becomes the picker's `selectionLimit`, so the selection
cannot exceed what the registry will accept and the refusal after the fact is only
reachable by a page that never released what it holds.

`getDocumentAsync` takes no selection limit, so for Files the room is the up-front
refusal only; `mint` remains the bound that cannot be skipped.

Each pin reds on its own mutation: dropping the up-front refusal, putting
`selectionLimit` back to 0, and fixing the limit at the cap instead of the room.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): ask for the library permission on iOS only (OTA phase C, C7.4)

Read from expo-image-picker 55.0.24, not from the docs. Neither platform's
`launchImageLibraryAsync` gates on a permission: `launchCameraAsync` calls
`ensureCameraPermissionsAreGranted` on Android and `hasGrantedPermission` on iOS, and
the library arm goes straight to its contract on both. On Android
`getMediaLibraryPermissions` answers an empty array from API 33, so the request
prompts nothing and always resolves granted; below 33 it asks for
`READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE`, which the system picker never
reads, and a denial there reached `native_media_permission_denied` for a pick the OS
would have completed. Android no longer asks.

iOS still does, because the prompt inside `pick` is what ruling 6 asked the shell to
own and the photo-library dialog is a real thing a user sees there.

One finding for the lead rather than a silent change: with `allowsEditing: false`
the iOS path is `launchMultiSelectPicker`, a `PHPickerViewController`, which also
opens without authorization. So the iOS request is the ruling's and not the SDK's,
and a denial refuses a pick that would have worked. Dropping it is a ruling-6
amendment, not mine to make.

Both arms pinned and both red under their mutations: asking everywhere fails three
cases, asking nowhere fails two.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): mock react-native in the device-verb hook suite (OTA phase C, C7.4)

The hook reaches `native-media-device.ts`, which now imports `Platform`, and reaching
the real module imports React Native — which this suite has no runtime for, so it
failed to load and ran zero tests while the run still reported every other file
passing. The device half's own platform arms are `native-media-device.test.ts`; here
the OS only has to be one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): ask for no library permission on either platform (OTA phase C, C7.4)

Ruling 6b. `launchImageLibraryAsync` gates on nothing in expo-image-picker 55.0.24:
Android goes straight to its contract and iOS to `launchImagePicker(.photoLibrary)`,
and only the camera arm checks a permission. The prompt "inside the shell" that
ruling 6 asked for is the OS picker's own, so `pick { source: 'library' }` adds
none. `requestLibraryPermission` is gone from the deps, from the device calls and
from `pickFrom`, and with it the last way a denial could refuse a pick the OS would
have completed — Android below API 33 for storage permissions the picker never
reads, iOS for a `PHPickerViewController` that opens without authorization.

`native_media_permission_denied` stays in the refusal vocabulary, documented for the
first source that needs one rather than for a producer this build has.

Red-first, and driven through the whole verb rather than through `launchLibrary`
alone, since the request was `pick`'s and a case that only called the picker would
have passed either way: with the request restored, the pick on iOS calls the
permission API once and the deps still carry the member, and both cases red.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): sweep before release, and name one basis per ratio (OTA phase C, C7.4)

Two lows from round 2.

`release` was the only lifetime path that did not sweep first, so an expired handle
was still in the map when it looked and the page was told `released: true` for a file
the next sweep would have taken anyway — the opposite of what
`mediaReleaseParamsSchema`'s docstring promises it. It sweeps now, like every other
path that reads the map. Red-first with the reviewer's own probe: expired, then
released, answered true. The exact boundary is pinned while we are here, because the
sweep's comparison is inclusive and nothing said so: at exactly `MEDIA_HANDLE_TTL_MS`
the handle is gone, one millisecond earlier it is not.

The raw ceiling was called "three times the reply ceiling" in two places.
`CLIPBOARD_IMAGE_MAX_SOURCE_BYTES` is 18,874,368 against 8,388,608, which is 2.25;
three is the ratio after base64 expands it. Both sentences now name which basis they
are on, and say that the two differ.

Each pin reds on its own mutation: dropping the sweep from `release`, and loosening
the sweep's comparison to exclusive.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never sweep a uri this shell does not own, and weigh before copying (OTA phase C, C7.4)

Round 3's five items.

(1) `owned.push(uri)` ran before the ownership guard, so a copy that could not be
adopted sent the provider's own `content://` uri to the failure path's discard — the
one thing the array's docstring says it never holds. The push now happens below the
throw, inside a named `adopt` that either hands back a uri this shell owns or throws
having claimed nothing. Red-first: the refusal case discarded exactly
`["content://media/external/images/media/42"]`.

(2) The copy's docstring claimed the in-memory read was bounded by a ceiling checked
"right after", which ran after the whole `bytesSync()`. The source is now weighed
before the copy, which is real: `FileSystemFile.size` routes a `content:` uri to
`SAFDocumentFile.length()`. A provider reporting nothing answers 0 there, so the
docstring also says what is true for that case — the read is bounded only by
`mediaTypes: ['images']`, and the copy is weighed after the fact instead. The copy's
own size stays authoritative for the handle.

(3) Both `react-native` mocks went inert when `324c4093be` dropped `Platform`. Gone,
along with the two-platform loop that had become one code path run twice; the 6b
guard now reads "never calls `requestMediaLibraryPermissionsAsync`", which is what it
was checking.

(4) The hook case no longer claims a permission prompt.

(5) The 150-character comment is wrapped, along with the three other comments over
100 that I had authored in this file and the two platform modules.

Each behavioural pin reds on its own mutation: pushing to `owned` above the throw,
and dropping the pre-copy ceiling check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse what cannot be weighed, and count a Files pick before staging it (OTA phase C, C7.4)

(a) The pre-copy weigh is now a mechanism rather than a docstring, and the branch it
takes is the one the SDK allows. Verified in expo-file-system 55.0.26: a `content:`
uri reads its size through `SAFDocumentFile.length()`, so the common case is weighed
before `bytesSync()` materializes anything. There is no bounded read to fall back on
for the rest — `File.open()`, `readableStream()` and `writableStream()` all reach
`FileSystemPath.javaFile`, which throws `This method cannot be used with content
URIs` outright, leaving `bytesSync()` as the only read a provider uri has and it is
all or nothing. So an item whose provider reports no size, which answers 0 and is
indistinguishable from an empty file, is refused rather than read at an unknown
size. An empty pick was nothing to stage either way.

(b) `getDocumentAsync` takes no selection limit, so the room `pick` hands it was
advisory and a user could return more than the registry holds. Counted before
staging: previously all nine assets were copied into the cache and `mint` refused the
lot, which the red run showed as nine discards. The refusal is
`native_media_handle_cap` and it sweeps nothing, because nothing of this shell's
existed yet.

Three pins, each red on its own mutation: dropping the unweighable refusal, making
the room check a no-op, and taking it off the Files arm.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the last two traces of the library permission prompt from the media tests

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 08:32:52 -04:00
Jinwoo Hong f5d2d6e757 feat(mobile): carry browser screencast frames over the bridge as base64 (OTA phase C, C6.1) (#21758)
* feat(mobile): carry screencast frames over the bridge as base64 (OTA phase C, C6.1)

`bridge-screencast-binary.ts` landed in C0 as the page's half of the binary
lane and named C6 as the owner of the encoder that satisfies it. This is that
encoder, plus the host honouring `wantsBinary`: a subscribe that asked for
binary gets an `onBinaryFrame` on the native stream, and each frame crosses as
the envelope's `event.binary` on the same `seq` ledger as the stream's JSON
events, because the page acks by that count.

The base64 encoder is grouped rather than per byte or per `fromCharCode`
window. Its docstring carries the measurement, including the part that
contradicts the design note this came from: on V8 the per-byte form is the
fastest of the three, not the quadratic one, and the chunked form it was meant
to beat is the slowest. The grouped one is here because its cost does not
depend on how an engine ropes `+=`, and Hermes is what the shell runs.

No new opcode, no `v` bump, no negotiation added: `wantsBinary` is already in
the contract and is the negotiation. Over-cap behaviour is unchanged in this
commit — a binary event over the frame cap still ends the stream, which is what
C6.2 changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): drop an over-cap screencast frame instead of ending the stream (OTA phase C, C6.1)

Measured at the pane's own request parameters, a screencast frame exceeds the
640 KiB envelope on a phone layout whenever the page will not compress: JPEG's
worst case is 0.545 bytes per pixel at quality 72, so mobile view mode at
780x1424 is 811,289 bytes, 124% of the cap. Ending the stream there blacks out
a browser tab for the life of the pane over one frame.

So the two kinds of event part at the cap. A JSON event that will not fit still
ends the stream with `overflow`, because its reader cannot see the hole it
would leave; a screencast frame is dropped and the stream lives, because the
next frame is one throttle interval away and the pane is still showing the last
one. Both are asserted side by side so neither turns into the other.

A drop leaves no other trace: the diagnostic beside it prints once per host, so
a stream shedding a frame a second and one that shed a single frame read the
same. The host therefore counts them per stream for the diagnostic and keeps a
session total, and the shell's dev facts carry that total — the surface that
already shows build state, with the line moved into its own module so what it
says is pinned rather than inferred from a template. The 12-character build
prefix it has always shown is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name the binary screencast lane as a grant (OTA phase C, C6.1)

Ruling 5's negotiation, and the check it asked for first: no reader of a grant
is a closed enum, so there is no blocker and nothing an older page has to
tolerate. `BridgeGrantsSchema.native` and the shell's manifest reader are both
open string arrays, and the shell reader's own docstring already states the
degradation — a grant name a build does not know leaves that one route native
rather than refusing the bundle.

What does constrain the name is the host contract's `GRANT_NAME_PATTERN`: a
grant is one camelCase token or a `native.<domain>.<action>` verb with at least
two dot segments. So `browser.screencast` and `native.screencast` are both
refused, and the lane is `screencastBinary`. `screencast` alone would be wrong:
the page can already subscribe to `browser.screencast` and receive its JSON
events, and only the binary frames need the encoder.

Added to the shell's implemented set, which is the same list `init.grants.native
` offers, so a route declaring it is served by a shell that has the encoder and
left native by one that does not. No route declares it here; C7's session route
does.

The contract-side case is a characterisation pin, not a red-first one: the
pattern already admitted this name, and the test records that the two tempting
spellings are the ones it refuses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): check the dropped-frame total through the bridge hook (OTA phase C, C6.1)

The hook gained a required `onBinaryFramesDropped` two commits ago and this
test kept calling it without one, so the tests-typecheck ratchet went red on
that commit — caught here rather than in CI because an exit code was read off a
pipeline's last stage instead of the script.

Fixed by wiring the callback into the probe rather than by a cast, and with the
case that makes the wiring evidence instead of types: a dropped frame raises
the total the screen receives, and the stream stays subscribed while it does.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): keep the dropped-frame counter with the ledger it belongs to (OTA phase C, C6.1)

Declared between a getter and a method, which is not where this class keeps
state: the subscription map is at the top and the counter is the same kind of
thing. Move only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the binary screencast lane only to a route granted it (OTA phase C, C6.1)

Reported as a gap after C6.1's third commit and ruled on: the host honoured
`wantsBinary` from any page, so a route that never declared `screencastBinary`
could still make the shell encode base64 on its behalf. That is the hole
per-route grants exist to close — the same class as a route granted only
`navigate` and `storage` reaching the clipboard.

The rule now reads the session's resolved list, which is what its route
declared narrowed to what this shell implements, and is the same set
`init.grants.native` is built from. So the host offers the lane in `init`
exactly when it will serve it.

Ungranted is not a refusal. The subscription proceeds and its JSON events cross
as before, which is the silence every other grant gives at the call site; a
page that reads its own grants never reaches that state. Both branches are
pinned beside each other, and `grantsForRoute` is pinned dropping a grant this
shell does not implement — granted-but-unimplemented and never-granted arrive
at the host as the same absence, so its rule reads one case.

The grant name moves into the module that holds the rule reading it, so the two
cannot drift. `bridge-host.ts` is at 298 of its 300-line cap after this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move the page's stream-frame rules out of the host (OTA phase C, C6.1)

`bridge-host.ts` reached 298 of its 300-line cap, so the next main merge that
touched it would have crossed under CI pressure on someone else's PR. Split
deliberately instead, at the boundary the growth came from.

`bridge-host.ts` is the host's lifecycle and its dispatch. Opening a stream is
the only frame kind whose handling is more than one line of delegation — four
refusals and, since C6.1, the binary-lane decision — so it moves whole, and
`cancel` and `ack` move with it so all three stream frames are decided in one
place. The host's `cancel` arm still chooses between a stream and a request
where it always did: a page's `cancel` names one or the other, and splitting
that choice would leave half an arm in each module.

Counted without blank lines or comments, as the rule counts them:
bridge-host.ts 298 -> 270, and the new module is 59.

A pure move. No test changed and none was added, which is what makes the
existing suites the proof: 45 files and 745 tests green on the same assertions
as before.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): report a page that asked for screencast frames it was not granted (OTA phase C, C6.1)

An ungranted `wantsBinary` is not a refusal on the wire, so nothing crosses
back: the subscription proceeds and its JSON events cross as they always have.
That left a page which did ask getting JSON for the life of the document with
no side able to say why. `notify-refused` has covered the equivalent notify
case since C0; this is the same shape for the one frame kind that lacked it.

The rule now answers a verdict rather than a boolean, because `not-asked` and
`ungranted` are the same answer for different reasons and only one is worth
reporting. So the decision and the report read one rule, and a page that never
asked stays silent — pinned, along with a granted route staying silent, so the
line cannot start firing on either.

The wire is unchanged and pinned unchanged: the case beside this one still
asserts one JSON event delivered and zero error frames.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): reset the dropped-frame total with the host that counts it (OTA phase C, C6.1)

Round 1 on #21758, three findings.

The real one: the count is per host and the screen's copy was not. A rebuilt
host starts its own total at zero, so the screen kept the retired host's number
until the new one dropped a frame and then read *lower* — a falling count looks
like frames coming back, which is worse than starting over. The hook now
announces a fresh count as it builds a host. That also reports zero on the
first build, where the screen is already at zero and React bails out of the
render; the two hook cases pin that leading zero rather than leave it to be
rediscovered.

Two docstrings that described nothing: `BUILD_ID_PREFIX_LENGTH`'s stayed behind
when the constant moved to the dev-facts module and had drifted above
`failureMessage`, and `page-route-policy.test.ts` kept the docstring of the
test it replaced above the one that replaced it. Both deleted; the first's text
lives on the new module.

Red-first for the reset, checked against its final expectations rather than its
first: with the one line reverted both hook cases fail on the missing zero, and
both pass with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the dropped-frame total out of a production build's render path (OTA phase C, C6.1)

CodeRabbit's Major on #21758. The total went into React state on every dropped
frame in every build, and outside a development build the line that reads it
renders null — so an over-cap page re-rendered the whole shell screen up to ten
times a second for a fact nobody can see. Measured, not argued: five drops,
five extra renders.

Fixed at the seam rather than with a ternary at the call site. The dev-facts
module owns the line, so it now owns the number behind it and the rule that the
number is only state where something renders it. The screen holds no flag and
no counter; it asks for both and passes the reporter on. The reporter is stable,
so the bridge host is never rebuilt for it.

`isDevelopmentBuild` becomes a call rather than a module constant. A build flag
never changes at runtime so this costs nothing, and as a constant the branch was
unreachable to anything that did not set the global before the module loaded —
which is why the production case could not be written at the screen at all.

Also fixed, found while writing that case: the screen test's
`usePageHostSnapshot` double returned a fresh object on every render, so the
host effect's identity changed each time and the bridge host was torn down and
rebuilt on every render of the screen, settling every pending request with it.
The real hook holds the snapshot in `useState` and is stable. One object for the
file now. This was masking the fold under test — the count reset to zero on
every render — and every other case in that file was measuring a rebuild storm.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* perf(mobile): price a screencast frame before encoding it (OTA phase C, C6.1)

Round 2 on #21758, two lows.

The encode is a base64 pass over the whole image and the window decides whether
the frame can be posted at all, so deciding after encoding made a page that had
stopped acking pay for every frame the shell then threw away — the reviewer's
case is ten 300 KB frames against a closed window, 3 MB encoded and nothing
sent. The size is knowable without encoding: base64 is ASCII, so JSON escapes
none of it and the frame is its header serialized plus exactly the image's
encoded length. `encodeBridgeScreencastFrame` is now built from that header
rather than beside it, so the shape measured and the shape sent cannot drift,
and the window arithmetic is one rule read before the encode and again on the
frame that was.

Exact, not conservative, so the drop diagnostic still reports the whole frame
and the committed byte pin is untouched.

Red-first with the real encoder wrapped in a counter: window full, ten frames,
ten encodes before and zero after, with the drop count still ten. An over-cap
frame likewise goes from one encode to none. A third case holds the other
direction — two carryable frames still encode twice — so the fix cannot pass by
encoding nothing.

Second low: the dev-facts block sat outside the only `beforeEach` and left
`routeGrants` and `client` mutated, inert only because it runs last. The shared
setup moves to file level where the mutable dependencies actually live, resets
both, and a case at the end of the file pins it — deleting the reset fails
there and nowhere else, since nothing else runs after a case that mutates them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:19:00 -04:00
Jinwoo Hong c22c442fdb feat(mobile): answer native verbs on the shell, clipboard first (OTA phase C, C2.4) (#21623)
* feat(mobile): declare the native verb table and advertise it (OTA phase C, C2.4)

The contract half of the shell-answered request seam: the `native.` prefix, a
typed table with params and result schemas per verb, and the two clipboard
verbs.

`MOBILE_WEB_SHELL_GRANTS` spreads the table's own name tuple rather than
restating it, so a verb cannot be advertised without a row and a row cannot
exist unadvertised — the table is `Record<BridgeNativeVerb, …>`, so a missing
row does not compile, and the suite holds the other direction. Verb names go
in the flat grant list on purpose: a route may declare one, and a shell that
lacks it keeps that route native rather than walling it.

The mime shape admits `image` because a later build will serve one; this one
refuses it, and the reason will say out of scope rather than unsupported,
since `expo-clipboard` implements the image calls.

No frame kind is added and no protocol version moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): answer native verbs on the shell and fence them from the desktop (OTA phase C, C2.4)

The host half of the seam. `forward()` is the one place a request reaches the
client, so the `native.` check sits there and nothing about ids, caps,
settlement or cancel moves: a native request takes a pending slot and settles
on the same frames as a forwarded one.

`readBridgeNativeVerbCall` is the whole decision, separate from the host so
the `ungranted` arm can be exercised at all — every page is offered every
verb this build implements, so through a real host that arm is unreachable
today and is the point of the check once a grant is per-route.

Refusals carry `native_verb_refused`, which the desktop's vocabulary does not
contain: an unlisted method comes back from `MOBILE_RPC_METHOD_ALLOWLIST` as
`forbidden`, so reusing that would make a leaked fence read as an ordinary
scope refusal. Every case in the host suite reads `client.requests` for the
same reason.

`_meta` is omitted from host-authored replies per the ruling, which required
making it optional on `RpcSuccess`/`RpcFailure`: the type required a field the
wire never has. `isRpcResponse` does not read it, `runtime-rpc-envelope`
already makes it optional on a failure, and nothing in this app reads it —
every occurrence is a fixture writing one. Zero other type errors resulted.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): restore the harness verb-type import and drop an unused one

Two leftovers from threading the native reply type through and then removing
it: the host harness lost its `BridgeNativeVerb` import, and the request
module kept a type import nothing uses. `tsc` and oxlint both failed on the
previous commit; this is the follow-up rather than an amend.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): typecheck the native verb suites and drop the dead reply type

Three leftovers the ratchet caught, none visible to `tsc -p tsconfig.json`,
which excludes test files:

- the fence suite read `frame.payload` off the whole `reply` union, and a
  chunked reply has no `payload`; it narrows on the field now
- the bridge hook's own suite builds its caller options inline and had no
  `serveNativeVerb`
- `BridgeHostAuthoredReply` became unused once `_meta` was optional, and an
  exported type nothing reads is the pattern round 2 of C2.3 flagged; the
  statement it carried already lives in the verb table's header

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the page a typed surface for the native verbs (OTA phase C, C2.4)

`useNativeVerbs` is the page's side, typed from the same table the host
serves, so a verb cannot be called with params the shell will refuse.

Each call goes out as an ordinary `request` and settles on the ordinary
frames; the method name is the whole difference. A verb the shell did not
grant is refused before a frame is sent, because a rejection after a round
trip and one that never left look identical to an `await` and only the first
costs an in-flight slot — `granted` is exposed so a caller can pick its own
fallback instead.

Results are parsed rather than trusted: the shell is a different build than
the page, and a result shape that moved should fail at the seam rather than
halfway through a screen reading a field that is not there.

No call site uses it yet; the two `Clipboard.setStringAsync` sites are the
consumer PR's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send native verbs from the module that owns the request port (OTA phase C, C2.4)

`use-native-verbs.ts` called `client.sendRequest` directly, which the
unvalidated-request-port boundary refuses: new code must send through an
`RpcOperation`, and nothing may be added to the inventory.

An `RpcOperation` is not available to this seam. Its `method` is typed
`RpcMethodName`, which is `keyof typeof RPC_PARAMS_BY_METHOD` from the
desktop's generated params catalog. Putting `native.clipboard.read` there
would declare that the desktop serves a method the whole fence exists to keep
off it.

So the send moves into `bridge-rpc-client.ts`, already listed as an owner of
the port — a module that implements the port rather than a call site picking
its own method and acceptance. `callNativeVerb` rides the same frame, id
space and in-flight cap as any request, and the page surface stays a thin
typed wrapper that reaches no raw port.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fence native methods on subscribe, not only on request (OTA phase C, C2.4)

The fence sat in `forward()`, which is the one place a *request* reaches the
client. A `subscribe` reaches the same client by another door: a frame naming
`native.clipboard.read` opened a real stream on the desktop, and because
`client.requests` stayed empty the whole suite read as green over it.

Refused in `handleSubscribe` before the id is claimed, under the same
`native_verb_refused` code, so nothing about the frame reaches the desktop or
occupies a slot. Cancel needs no arm of its own: it can only settle an id
that was admitted, and none is.

The oracle is widened with it. Every case now reads the client's streams as
well as its requests, because the old one could not see this at all — an
absence that only ever looked at half the boundary.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold a native verb's answer to the result it declares (OTA phase C, C2.4)

The table names a result schema per verb and the host never applied it, so a
handler could answer `{ nonsense: 1 }` and the page's own parse would be the
first to notice — halfway through a screen, not at the seam.

Validated on the host and refused by name on a mismatch, which is what makes
the table's claim true on the side that serves it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the native verb member from being a raw port (OTA phase C, C2.4)

`callNativeVerb(verb: string, params: unknown)` took any method, so
`callNativeVerb('worktree.list', …)` reached the desktop through the real
pair — a raw request port in the one module allowed to hold one, and invisible
to the inventory, whose scan counts `.sendRequest` shapes and not a bare
call inside the owner.

The parameter is typed `BridgeNativeVerb` now, which is the fence for every
caller the compiler can see, and the prefix is checked at runtime for one
that reached the member through a widened type. The compile-time half is
pinned by a `@ts-expect-error` the tests-typecheck ratchet holds: widening
the parameter back makes that directive unused and fails there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give every native verb refusal one typed error at the page (OTA phase C, C2.4)

Only the seam's own refusal carried `native_verb_refused`. A handler that
declined and a reply too large to send arrived as other categories with no
code at all, and the hook rethrew a bare `Error(message)` — so a caller
telling an out-of-scope mime from an unsendable clipboard had to read message
text, and those want different handling.

Three changes, one shape. The host re-raises a handler's failure under the
seam's code, keeping the handler's message because that is what says why.
`BridgeReplyUndeliverableError` carries its frame refusal as a code, so
`reply-too-large` survives to the page. The hook throws `NativeVerbError`
with a `reason` read off the code `reconstructBridgeError` already copies
onto the rejection, plus `ungranted` for the arm this side decides.

Removes the unreachable `ok: false` branch from the hook with it. The
narrowing it was doing moves into the client member, which now promises a
success or a rejection and nothing else.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): test the in-flight cap and cancel, not the duplicate-id branch (OTA phase C, C2.4)

The case named for the cap sent the same id twice, so what it exercised was
the already-in-flight check. It never held a second slot and would have
passed against a seam that took none.

It now fills the cap with distinct ids against a handler that never settles,
and asserts the one over it is refused with the cap's own message. A cancel
case goes with it: a native request cancelled before its handler settles
posts nothing afterwards, the way a forwarded one does not answer an
exchange the page has moved on from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the native verb member's type with a directive, not a cast

The case proving a desktop method cannot go through `callNativeVerb` reached
the runtime guard with `as never`, which the casting gate refuses — and a
cast is the wrong tool anyway: it asserts past the very type the case exists
to pin.

`@ts-expect-error` instead, which the tests-typecheck ratchet holds: widening
the parameter back to `string` makes the directive unused and fails there.
The call still runs, so the runtime guard is exercised too.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): parse the shell's error code instead of reaching for it (OTA phase C, C2.4)

The anti-slop audit refuses `Reflect.get`: dynamic input is parsed into a
named shape before it is read. `code` is not a property of `Error` — it is
whatever `reconstructBridgeError` copied onto the rejection from the capture
— so a schema is the honest reader here, and it says what this takes without
asserting the rest away.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the id collision before the native fence on subscribe (OTA phase C, C2.4)

The fence ran before the already-in-flight check, so a `subscribe` naming a
`native.` method under a live request's id settled that request with the
fence's message. The page lost the request either way — the collision class
predates this PR — but it was told the wrong cause, which is the difference
between a bug it can see and one it cannot.

Collision first. The fence still runs before any slot is taken, so nothing
about the frame reaches the desktop.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): parse a native verb result once, inside the catch (OTA phase C, C2.4)

The result was parsed twice: by the send path against the table's schema, and
again at each caller against the concrete one. The second parse was dead, and
it sat outside `call`'s catch, so a shell answering a shape the page did not
expect would have escaped as a bare `ZodError` — the one shape this surface
promises not to throw.

`call` takes the verb's result schema and parses once, inside the catch, so
every failure leaves as a `NativeVerbError`. The params parse at the callers
goes with it; the host validates params and the page builds them typed.

Also moves the comment block documenting `onExternalLink` back above it,
which `serveNativeVerb` had landed in front of.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give every native verb refusal its own code, and keep handler words on the device (OTA phase C, C2.4)

Two findings that had to land together. Six faults all arrived as
`native_verb_refused` and differed only in message text, which the hook's own
comment said nobody may switch on. And a handler's message crossed verbatim:
a clipboard read that failed after reading is free to put what it read in its
error, and the error frame is the only path out of this seam that is not a
declared result.

So each fault gets a code — unknown verb, ungranted, bad params, wrong
result, out of scope, handler failure, native-on-subscribe — and the three
paths that reached the page uncoded get one too: the in-flight cap, a
non-native method through a widened member, and host disposal. `reason` is
now drawn from a declared list with no `unknown` arm, asserted at the hook.

A handler's code crosses and its message does not; the shell logs the real
one. The out-of-scope mime stays distinguishable because the code carries it,
not the text.

`bridge-host.ts` crossed the line cap with this, so the serving half moves to
`bridge-host-native-verbs.ts` — read the call, serve it, hold the answer to
what the verb declares — leaving the host the frames around it. No cap was
disabled or raised.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse unknown verb params, and floor an unknown code (OTA phase C, C2.4)

Two the bots caught, both about a shape one side does not know.

`z.object` strips unknown keys, so `{ mime, value, unexpected }` dispatched
as if the extra key had not been sent — and the page and the shell are
separate builds, so a param the shell silently ignores is the shape of a verb
that changed underneath a page. `z.strictObject` on the verb params and
results.

And the page passed any code through as `reason`, while its own doc and
`NATIVE_VERB_REASONS` promised a closed list; a shell newer than the page
would have fallen off the end of a caller's switch. Unrecognised codes floor
to `unreported`, `reason` is typed to the list, and the doc says what the list
actually is rather than the single code the per-arm ones replaced.

The flooring is tested by delivering the frame such a shell would send: this
build's host normalises an unknown code before it leaves, so the pair cannot
produce one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve no request before the page has asked for a session (OTA phase C, C2.4)

`serving` starts true so a page's first frames are not refused for arriving in
the same native batch as its `ready`, but nothing checked whether an `init`
had ever been sent. So a request from a document this host had told no caps,
no grants and no route was forwarded to the desktop, or served as a native
verb, while the notify path had refused exactly that since C0.

Gated on `initSent`, under the protocol's own `before-ready` name. Streams are
left alone: the finding names requests, and gating `subscribe` too is a wider
change than it asked for — worth its own decision, since the same hole is
there.

Fourteen host cases and five hook cases were relying on this: they open a
request without ever asking for a session, which no real page does. They take
a `ready` now, through a harness option, and the counts that read what the
host posted account for the `init` a session opens with.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): grant a page session what its route declared, not what the app can do (OTA phase C, C2.4)

`init.grants.native` handed every session the shell's whole capability set,
so a route declaring only `navigate` and `storage` was granted
`native.clipboard.read` as well. That was harmless while every grant was a
navigation or a write the page could make anyway. It stopped being harmless
the moment a verb reads something back, which is this PR.

The session is now granted the intersection of what this shell implements and
what the mounted route declared in `MOBILE_WEB_PAGE_ROUTES`, plus the
protocol's own `fault`. One list: `init` issues it and every grant check —
notify and native verb — reads the same one, so what a page is told it may do
and what it will be served cannot drift.

`MOBILE_WEB_SHELL_GRANTS` and `implementsGrant` are unchanged; the shell's
capability set is still the ceiling a route's list is drawn from.

User-mediated authorization is not attempted here and goes to C2.7 as an open
question.

`use-mobile-web-shell-session.ts` crossed the line cap with the extra field,
so the three effect workers that touch the network and the disk move to
`mobile-web-shell-session-effects.ts`, leaving the hook its reducer and
callbacks. No cap was disabled or raised.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): key the pre-handshake refusal on the session, and apply it to streams (OTA phase C, C2.4)

Two rulings, one mechanism.

The gate keyed on the host instance, and a host is rebuilt whenever the client
under it changes. The page does not know: the session id is the same, so it
neither re-handshakes nor hears that the shell was replaced. So a live page's
next request was refused, and would have been until reload — a regression, not
a safety gain, and not covered by the in-flight settling as delivery-unknown.
The host now inherits whether its session already handshook, which the hook
records when the page first asks.

And the rule is about the session rather than the frame kind, so `subscribe`
is gated with `request`: a stream opened before the handshake was the same
hole.

Fourteen stream cases were exercising a state the protocol forbids — they
subscribe without ever asking for a session, which no page does. Every one is
about caps, backpressure windows, acks, cancel, idempotency or arity; none
was testing anything through the hole itself. They complete the handshake
now, and the counts that read what the host posted account for the `init`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): judge a cached fallback by its own routes and grants (OTA phase C, C2.4)

The newer manifest is read before the download is attempted, so its
`pageRoutes` and `routeGrants` are already on the session when the download
fails. Opening the cached generation then mounted an older page under a newer
bundle's grants: a cached route that never declared the clipboard would have
been granted it by a manifest it is not running.

The fallback now derives both from `cached.routes`, and applies that
generation's own render eligibility before mounting it — a route only the
newer bundle claims is not a route the cached page can serve.

This is the Phase D "grants across generations" item arriving early. Only the
grant side is fixed here; persisting a generation's grants with the
generation itself stays Phase D's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): remount the shell on a route change, so its bridge cannot outlive it (OTA phase C, C2.4)

A host captures the grants its session was opened with, and the agent-history
route renders `MobileWebShellScreen` with a pathname derived from
`worktreeId` and no key. So changing worktree updated the screen in place:
the old bridge stayed mounted and kept authorising frames under the grants of
the route the page had already left.

Keyed on the route now, which makes the change a remount — the old bridge is
disposed in the commit, before it can read another frame, and the new session
starts with no grants until its own `init`. The worktree-list and embedded-
browser routes are keyed on the host id for the same reason; the hazard is
the same whenever a dynamic segment moves under a mounted shell.

The probe that catches this uses an empty dependency array on purpose: keyed
on the pathname it re-fires on a prop update and reads exactly like a
remount, which is the one thing it exists to tell apart.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(shared): let a manifest declare a native verb as a route grant (OTA phase C, C2.4)

`GRANT_NAME_PATTERN` was dotless, and the contract's own pin asserted a
dotted grant is refused. So no manifest the desktop can produce could declare
`native.clipboard.write` — and once grants are scoped per route, a verb no
route can name is a verb no route is ever granted. Every native verb was
unreachable for every route.

The grammar now admits the verb shape the table names: `native.` followed by
at least two lowercase dotted segments, which is `native.<domain>.<action>`.
A plain name wearing a dot is still refused, `native.navigate` included, so
the pin keeps its meaning.

Wire compatibility, checked rather than assumed: widening what a manifest
field may contain is a new optional value reaching readers that shipped
before it, and the phone's reader already tolerates one. Its route schema
bounds a grant's length and nothing else, deliberately — an unknown name is
not a parse failure that would refuse the whole bundle, it is a grant this
build does not implement, so `implementsGrant` drops it and the route stays
native. Both halves are now tested: an unknown verb leaves its route native
and grants nothing, and a known one reaches `init.grants.native`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the session reducer suite by concern before main pushes it over the cap

Merged with main the reducer suite reaches 805 lines against a cap of 800 —
neither side alone crosses it, which is the case the lane rules warn about.

Split at a concern boundary rather than raised: the grant-facing cases (the
cached fallback's own routes, and a manifest verb reaching the session
grants) move to `mobile-web-shell-session-grants.test.ts`, and the fixtures
both suites drive the reducer with move to a shared module beside them, the
way the bridge host suites already share a harness.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 07:07:02 -04:00
Jinwoo Hong 57fdf68ab3 feat(mobile): let the page hand a link to the device through the shell (OTA phase C, C2.3) (#21597)
* feat(mobile): answer externalLink on the shell side of the bridge (OTA phase C, C2.3)

A page has no way to open a URL outside itself: `Linking.openURL` is the
app's, and inside the shell the page is a document that cannot reach it. Adds
`notify { name: 'externalLink', url }` and a new grant name of its own in
`MOBILE_WEB_SHELL_GRANTS`, rather than a verb of `navigate` — `navigate`
opens a screen this app carries, this hands a URL to whatever the device
opens it with, and a shell implementing one and not the other is a real shell
the route policy has to be able to describe.

`https:`, `http:` and `mailto:` only, and broad inside that: any host, any
path, because a grant that named GitHub would grow a row per provider. The
rule is parsed rather than prefix-matched, since a scheme is what a URL
parser says it is and `startsWith('https:')` reads one out of
`javascript:alert("https://x")`. It is enforced at the frame as well as at
the page's call site, so a page that skipped its own check still cannot reach
the device handler. Bounded by the route href cap, per the ruling.

`BRIDGE_PROTOCOL_VERSION` is not bumped. Inert until a consumer exists: no
call site and no barrel is touched here.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): plumb externalLink through the bridge hook probe (OTA phase C, C2.3)

The hook's own suite builds its caller options inline, so the new required
option made it stop typechecking. `tsc -p tsconfig.json` excludes test files;
only the tests-typecheck ratchet saw it.

Adds the case that goes with it: a URL the page hands over reaches the
caller that can leave the app, and nothing reaches the navigate path.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): let the page post an externalLink over the bridge (OTA phase C, C2.3)

`notifyExternalLink(url)` on the page client, gated on the `externalLink`
grant and on the same scheme rule the frame enforces.

Checked twice on purpose. Nothing crosses back for a notify, so the boolean
is the only answer a tap gets: a page that posted a URL the shell's reader
then dropped would report "opened" into a frame nobody acted on, which is
precisely the dead tap the grant exists to rule out.

False before `init`, false after `close`, and never a throw — the callers are
tap handlers with no catch around them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): add the external-link seam the tasks call sites will use (OTA phase C, C2.3)

One module with a `.web.ts` sibling, which is the shape every platform gap in
this bundle already takes. Native is `Linking.openURL` with the rejection
swallowed, because every caller is a tap handler and `openURL` rejects for a
URL no installed app claims. Web posts the `externalLink` notify after the
same scheme check the frame enforces, names its refusals and throws nothing.

The opener is published by the entry rather than read from context, for the
reason `publishPageStorage` is: the callers are plain functions in render
trees the provider does not wrap. A document that published none refuses
every URL, which is the right answer for a page with no shell.

Registered in `web-overrides.json`: inside the shell's WebView, react-native
-web's `Linking.openURL` opens the URL in that WebView and replaces the page
rather than handing it to the system browser.

No call site and no barrel is touched. The consumer PR swaps them onto this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the real reason the page cannot open its own links (OTA phase C, C2.3)

The override reason claimed react-native-web's `Linking.openURL` opens the
URL in the shell's WebView and replaces the page. It does not. Read from
react-native-web 0.21.2: `openURL` calls
`window.open(url, '_blank', 'noopener')` and resolves whether or not anything
opened; only a `tel:` URL assigns `window.location`, and none of the three
allowed schemes is one.

The true failure is the worse one and the better argument for the verb. Both
shells refuse `window.open` outright, measured in their own sources: iOS sets
`javaScriptCanOpenWindowsAutomatically = false` and returns nil from
`WKUIDelegate`'s `createWebViewWith`; Android sets the same flag false, calls
`setSupportMultipleWindows(false)` and returns false from `onCreateWindow`.
So nothing opens, `openURL` resolves anyway, and the native path reports
success into a tap that did nothing — precisely the dead tap the grant exists
to rule out.

The seam's own comment now says the same, so the two files cannot drift.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report a URL the phone could not open (OTA phase C, C2.3)

The shell swallowed `Linking.openURL`'s rejection. Nothing crosses back to
the page for a notify, so an open that failed — a `mailto:` on a phone with
no mail account — was silent on both sides. That is the one dead tap this
verb does not rule out, and it was the only one with no record at all.

Warned with the URL and the error, in the shape the two neighbouring reports
in this screen use, and still not rethrown: this runs on the native frame
handler. The contract now asks for the report rather than only for the
absence of a throw.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): forward the URL the parser read, not the string the page sent (OTA phase C, C2.3)

The scheme check reads the protocol through the WHATWG parser, which strips
tab, LF and CR from anywhere in a URL and trims leading C0 and space before
the scheme is visible. So `ht\ntps://example.com`, `https://example.com/a\r\n`,
`  https://example.com/a  ` and `https:example.com` all passed the check, and
both sides then forwarded the original string. Not a scheme escape — the
parser had already decided the scheme — but the device handler was given a
URL the check never looked at, which is a dead tap through an allowed URL.

`readBridgeExternalLinkUrl` answers the parsed href, and the page posts it
and the host forwards it. Normalizing rather than comparing, because
`https://example.com` differs from its own href by a path slash: refusing
what differs from its normalization would refuse an ordinary URL.

The cap now applies to the normalized form as well as the raw string, since
percent-encoding expands and a string inside the cap can leave it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): exercise the seam's default opener instead of a published one (OTA phase C, C2.3)

The case named for a document that published no opener published one first,
and `post` is module state every earlier case had already set, so the default
at the top of the module was never the thing under test. The only assertion
was `not.toThrow()`, which passes against any implementation.

`vi.resetModules()` and a fresh import, and the refusal reason is asserted.
Confirmed to discriminate: flipping the default to `() => true` turns this
case red and leaves the other three green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): build the openURL rejection per call, not at mock setup

The case added for a failed open used
`mockReturnValue(Promise.reject(failure))`, which builds the rejected promise
at setup time. Nothing attaches a handler until the notify frame arrives
several awaits later, so the suite reported an unhandled rejection and exited
1 with every test passing — a red run that reads as green in the counts
alone.

A fresh rejection per call closes the window, and `openUrl` is reset between
cases so the mock cannot leak into one that does not expect it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): guard the openURL failure that escapes a tap handler (OTA phase C, C2.3)

`Linking.openURL` validates before it returns anything: `_validateURL` is an
`invariant` that throws for an empty string (react-native 0.83.10,
`Libraries/Linking/Linking.js:117-123`). So the seam's `.catch` was attached
to a promise that, in that case, never existed, and the throw went straight
through a tap handler — contradicting the module's own claim to be safe in
one.

Both failure modes are now caught and reported, and neither is rethrown. The
test double validates the way the real module does, because a mock that only
rejects cannot reproduce the failure that escapes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): collapse the duplicate openURL wrapper onto the seam (OTA phase C, C2.3)

`mobile-pr-url.ts` was the seam's native body already, byte for byte, written
before it. It now re-exports the seam under its own name, so the empty-URL
guard and the failure report reach its four callers too.

Nothing changes natively: the seam's native form is what those callers were
running. On the web they would now post the notify instead, which is the
behaviour they should have had; it is unreachable today, since none of them
is in a page route's closure.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read externalLink end to end over the port pair (OTA phase C, C2.3)

The pair harness grew `externalLinks` and nothing read it. This is the
`navigate` twin's shape, over the six normalization inputs: what the page put
on the wire and what the shell forwarded are the same strings, read back off
the frames rather than recomputed, and nothing reaches the shell's client.

Two halves on purpose. The page normalizes before it posts, so over the
client the host only ever receives an already-normalized URL and forwarding
it raw would pass — the frame injected straight into the host at the end is
what holds the host to the rule on its own. Confirmed to discriminate:
reverting the host to forward `message.url` turns that assertion red.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): match the neighbours on three small inconsistencies (OTA phase C, C2.3)

Three of a kind, none behavioural:

- the notify guard's docstring had a 109-character line; reflowed
- the shell's could-not-open warning passed three arguments where the three
  other reports in that screen pass two; it now passes `{ url, error }`
- the native seam's suite built its rejection with `mockReturnValue`, which
  is the shape `52191bab02` removed elsewhere; it now builds one per call,
  through the same double that validates the way the real module does

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 03:33:41 -04:00
Jinwoo Hong 1ef947394b feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2) (#21582)
* feat(mobile): answer navigate-back on the shell side of the bridge (OTA phase C, C2.2)

A page served at `/` holds the one history entry its entry wrote with
`replaceState`, so `history.back()` goes nowhere and a page Back button is
dead. The only stack with somewhere to go is the native one the shell pushed
the page onto.

Adds `notify { name: 'navigate-back' }` to the closed client union, gated on
the existing `navigate` grant rather than a name of its own: an app that can
open a screen can close one, and a new grant name would leave every route
declaring it native on every shell already shipped. `MOBILE_WEB_SHELL_GRANTS`
is unchanged and `BRIDGE_PROTOCOL_VERSION` is not bumped.

`bridgeNotifyRefusal` grows a name-to-grant table, since this is the first
notify whose name is not its grant's. The shell screen pops its own stack and
answers false when there is nothing left, which the host logs as
`navigate-back-refused` — nothing crosses back to the page either way, so
silence there is indistinguishable from a Back button that worked.

Inert until a consumer exists: no page posts the name yet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2)

`useRouteHandoff` wrapped `push`, `replace` and `dismissTo` and left `back`
as expo-router's own, which inside the page pops a history of one and does
nothing. It now pops locally when the document grew a stack of its own, and
otherwise posts `navigate-back` for the shell to pop the native stack.

This is what makes the Tasks header's `onPress={() => router.back()}`
(`src/tasks/mobile-tasks-screen-chrome.tsx`) work once the C2.1 consumer
routes that screen's router through this seam; the barrel still reaches
expo-router directly, so nothing calls this yet.

A shell that granted no `navigate` falls through to the local router rather
than throwing out of a tap handler. A shell that granted `navigate` but is
too old to know the verb refuses the frame as `unrecognised-message` and
logs it; neither is distinguishable from the page, and the fallback goes
nowhere in both — which is exactly where Back already went.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): count back among the members that leave the page document

The header said three wrapped members driven by one answer. `back` is a
fourth, and it is not driven by that answer: it carries no target, so the
document's own stack decides it rather than the shell's route list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): complete the bridge hook probe in its two local literals

`Probe` grew `backPops`, and two cases build the object inline rather than
through `mount`. `tsc -p tsconfig.json` excludes test files, so only the
tests-typecheck ratchet saw it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): measure the stack the page Back button rests on (OTA phase C, C2.2)

`useRouteHandoff().back()` asks expo-router's `canGoBack()` whether this
document can serve a back itself, and that answer is React Navigation's, so
no unit test settles it. The render check now measures it in the browser it
actually runs in.

Read through `router.back()` on `/h/[hostId]/edit`, a real route of this tree
whose chevron is expo-router's own back, because the page exposes no handle
to call `canGoBack()` on and a global added for a test would ship forever.

Measured: the router has nowhere to go on the document the shell opens, and a
location change does not give it one either. So the handoff's `canGoBack()`
gate answers false for everything the shell or the browser can do to the
page, and its local branch belongs to a push the page makes through the
handoff itself.

The shell double now records every notify the page posts and takes the grant
list as a parameter, so a control that handed something to the shell can be
told from one that did nothing. The first case asserts a real tap crossing
the bridge, which is what makes the two absences after it evidence.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a second stack pop while the first is still queued (OTA phase C, C2.2)

`canGoBack()` and `back()` disagree about time. The first reads the committed
navigation state; the second only adds `GO_BACK` to expo-router's
`routingQueue`, which `useImperativeApiEmitter` drains from an effect. Two
`navigate-back` frames delivered in one native batch therefore both read the
stack the first pop has not left yet, both queue, and a three-deep stack
unwinds past the screen the page was opened over. The host forwards every
notify it is granted, so nothing upstream coalesced them.

`useShellStackPop` owns the pop and latches it. The latch clears on the
committed route rather than on a timer, because that commit is the first
moment `canGoBack()` answers for the stack the pop actually left; a pop that
takes this screen off the stack unmounts it and takes the ref with it.

`onNavigateBack` now answers `popped` / `nothing-to-pop` / `pop-pending` so
the `navigate-back-refused` diagnostic is true for the frame it names, and
the log dedupes per reason rather than burying the second behind the first.

Driven against expo-router 55.0.18's own `global-state/routing.js`,
evaluated verbatim with only its externals stubbed: a mock of `canGoBack`
is what hid this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the notify grant table total over the union (OTA phase C, C2.2)

The table was `Record<string, string | undefined>` indexed with a plain
string, so a notify name with no row returned undefined, read as ungated,
and the host acted on a frame it had never granted. Adding a member to the
envelope's notify union raised no error anywhere — the hole was silent.

Keyed on `Extract<BridgeClientMessage, { type: 'notify' }>['name']` with
`string | null` values, an omitted row is now a TS2741 on the table itself,
and `bridgeNotifyRefusal` cannot be asked about a name the table has no row
for.

Adds the `navigate` and `storage` rows, which were missing: the host was
enforcing the navigate grant for `navigate-back` but not for `navigate`.
Both are inert while every page is offered every grant, and load-bearing the
moment a grant is per-route.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): assert no page errors in the browser case that drives popstate (OTA phase C, C2.2)

The case that dispatches a synthetic `popstate` read its answer as "the page
did not move", and a throw under the page's fault boundary leaves the page
exactly there. Without the errors assertion the other two cases carry, that
absence was not evidence of what it claimed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the routing module's members instead of asserting them

The casting gate refuses the two assertions the loader used, and it is
right: a stub that stopped covering an import would have left the members
undefined and the test would have driven a half-evaluated module. Destructure
and check instead, so that case says so.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the two pops the stack latch never hears about (OTA phase C, C2.2)

The comment read as if the clear were exhaustive. It is not: a pop landing on
an equal pathname does not transition `usePathname`, and a `GO_BACK` the queue
discards commits no route at all — `routingQueue.run` shifts every action off
the queue whether or not `ref.current` is set.

Kept the pathname clear rather than moving to the navigator's `state` event.
The event would cover the first stick: `@react-navigation/core` 7.17.2 emits
`state` from an effect keyed on the navigator state object, and every pop
replaces it. It would not cover the second, which changes no state. And the
emitter is the navigator, not the routing module this hook is written and
tested against, so the switch cannot be earned by a test here the way the
queue behaviour was — it would rest on a mock of the signal under test.

Both sticks are bounded instead, in the commit that makes the latch one per
stack: the holder releases on unmount, so a stick lasts at most as long as the
screen that took it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold one pending stack pop per stack, not per screen (OTA phase C, C2.2)

`MobileWebShellScreen` mounts at both `app/h/[hostId]/index.tsx` and
`app/h/[hostId]/web.tsx`, and `/h/a/web` is deep-linkable over `/h/a`, so two
shells can be mounted over one native stack. A latch per screen left each of
them holding its own, and two frames from two pages still unwound two screens.

The latch is module-scoped and carries which screen took it, so a shell whose
own route commits cannot release a pop another shell is still waiting on. The
holder also releases on unmount, which is what bounds the two pops the
pathname clear never hears about: a latch nobody is left to release would
outlive the stack it guards and leave Back dead for the session.

Both screen suites now unmount their trees between cases, because a tree that
is only dropped is a screen still holding whatever pop it took.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the gated-notify name list nothing reads (OTA phase C, C2.2)

`BRIDGE_GRANT_GATED_NOTIFY_NAMES` existed so a caller could ask which names
ride a grant. Once the table became total over the notify union, the table
itself answers that and the only readers left were the two assertions that
read the list for its own sake.

Deleted with them. The behaviour they stood next to is kept: the protocol's
own names are still asserted ungated through `bridgeNotifyRefusal`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the routing-module guard actually catches (OTA phase C, C2.2)

The comment claimed the guard catches a stub that stopped covering an import.
It does not: evaluating the module with every stub dropped still defines all
three exports, because the module assigns them whatever its imports resolved
to, and the failure surfaces later as `TypeError: Cannot read properties of
undefined (reading 'navigationRef')`.

What the guard does catch is an expo-router upgrade that renames or removes
one of the three members this test drives.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the second shell mounted across the holder's removal (OTA phase C, C2.2)

The harness swapped the tree's root element between a single shell, a
fragment of two, and a single shell again. Each swap remounts everything
under it, so the callbacks the cases held belonged to unmounted hooks. One of
those could still take the module-scoped latch, and the instance that took it
was already gone, so nothing was left to release it — the last case in the
file leaked the latch into whatever ran next.

The root is now one component with a slot per shell, so removing the holder
leaves the second shell's instance alone, and every pop is taken through a
callback re-read at call time.

`afterEach` now asserts the latch is clear by mounting a screen after every
other one is gone and requiring it to pop. Without it this leak was invisible:
it surfaces only in a following case, and the case that caused it was last.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 02:09:09 -04:00
Jinwoo Hong e2afb5eef9 feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) (#21503)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4)

Three gaps the census named, and the last of them is why the page needed a second
grant.

`expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with
an empty array and the list painted "Host not found" over the host the shell had
just opened it for. `init` gains an optional `host`, and `host-store.web.ts`
serves it: the profile the screens read, with no device token and no public key,
because the bridge already carries the connection those would have opened. The
native writes it cannot make — pairing, renaming, recency — settle rather than
throw, since recency orders a list the page never shows.

AsyncStorage's web build is `window.localStorage`, and the page has none worth
having: Android turns DOM storage off and on iOS the origin host is the session
id, so a pin set in the page was gone on the next remount. The builder aliases
the module to a page store whose values are the app's own — `init` primes the
allowlisted keys, a write is applied locally and posted over a new `storage`
grant, and the app is where it lands. The allowlist is two keys and is the whole
fence: everything the app stores shares one namespace, the hybrid shell flag
included, so a page that could write any of it could turn the feature on for a
build that never offered it. A key outside the list is refused and, crucially,
not kept locally either — a pin that looks set and is not is the failure the
grant exists to avoid.

The bridge host is built only once both have been read, because `init` is
answered once per `ready` and carries them: a host that started without them
would have to be torn down to carry them, and the list would already have mounted
against a host it could not name.

`Alert.alert` on a failed host removal is a silent no-op in React Native Web, so
inside the page that failure had no surface at all. It routes to the error the
list already shows, on both platforms.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the storage-write ref after the commit

The last render-phase ref write in this hook joins the commit-phase effect.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the confirm setter this callback already calls in its deps

A `useState` setter is stable, so the identity of the callback is unchanged; the list now
says what the body reads. Reported on the line this branch rewrote.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4)

Five, from one round of review.

A removal that fails no longer writes the identity error: that one is an early return
over the header, the list and the overlays, with nothing to dismiss it and nothing left
to render the confirm it re-opens. It goes to a dismissible line above the list instead,
on both platforms, cleared by the next confirmed refresh.

`init` reads the allowlisted keys on every answer rather than capturing them at mount, so
a document that reloads inside one mount is primed from after its own writes. The read
stays synchronous: the page refuses every member until `init` lands and the golden
recorder mounts a screen in the same turn it drains one, so a promise here moves the
first render of every bridged replay.

A profile read that rejects is now a shell failure with a diagnostic instead of a `ready`
session with no host behind it and a page asking forever. The page bounds a value by the
envelope's own constant rather than caching what the wire drops. And a write is held to
the keys this page was handed, so one host's page cannot rewrite another's pinned list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): state the two test fixtures' types instead of asserting them

The casting gate reads a `SAFETY:` rationale off the line directly above the assertion,
and a wrapped comment puts a comment there instead. Two of the four were not assertions
worth keeping at all: a hoisted fixture says its own type, and the router comes from the
mock the file already installs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the two fixtures in place, not through a widened binding

`const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate
refuses, and rightly: the evidence is discarded and then invented again. The assertion
belongs at the literal, with its rationale on the line above it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the key a refused page write reached for

The last diagnostic still falling through to the line about a view outliving its
host, on the branch that added it. The key is the evidence: it says which host's
pinned list the page was reaching into.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe this branch's storage writes too

Same merge, one branch further: a probe here also collects what the page asked
the screen to write.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the page the app's storage as it stands, not one init late

The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes
two of them from its own native screens. The shell's map was only re-read after a ready, so a
native write between two readies reached the init after next: the drawer opened on the repo the
user left. The map is now module-scoped and every writer of an allowlisted key notes it as it
writes, so the init that answers a ready already carries it. The store read only seats the map,
and a read that started before a write no longer puts the older value back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): find the banner's dismiss without an assertion

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): keep the storage mirror in the storage tree

The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the
dependency the wrong way round: the shell is what is built on the app's storage, not the other way.
The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller
names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour
change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 22:20:49 -04:00
Jinwoo Hong ac4dc6599b feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:53:58 -04:00
Jinwoo Hong b8f3b1ec00 feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) (#21501)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:21:48 -04:00
Jinwoo Hong d043cfbbee feat(mobile): the page mounts on the shell's init, with the client injected and a fault boundary (OTA phase C, C1.1) (#21500)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 20:45:03 -04:00
Jinwoo Hong f2be6299c8 feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) (#21459)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3)

One page document's end of the bridge: page frames in through the C0.1
reader, one RpcClient behind it, host frames out. Requests forward with the
arity the page used and answer with the verbatim RpcResponse, chunked when it
is over the frame cap; a rejection crosses as the five-field capture instead.
Subscriptions carry a seq and an unacked window, and end with `overflow`
rather than dropping frames a reader cannot see are missing.

The fence is structural: the protocol names no host, so the client is
whichever this host was built with, and the in-flight caps the page is told
about in `init` are enforced here rather than trusted from there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3)

The channel opens on the session B4 put on screen and closes with it. The
session id is B4's: nothing new is minted, and a remount is a new one, which
is what makes a dead page's frames fail the native origin check.

Both halves are stamped with the session they belong to, because React swaps
refs during the commit and runs the retiring effect's cleanup after it — a
host disposing on a remount would otherwise post its teardown into the page
that replaced it. `bridgeEnabled` is derived from the session step alone,
since the native side treats a prop change as a reload.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the bridge fence holds for traffic, not just for answers

A mutation that dropped the post-teardown guard in `receive` survived: the
teardown case only fed a frame whose answer the outbound guard already
swallowed, so nothing observed that a dead page could still reach a live
client. Both teardown paths now feed a request, a subscribe and a notify,
and assert the client saw none of them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the hook's frames through the page's own reader

`JSON.parse` returns `any`, and taming it with an assertion is a cast the
gate refuses and a check nobody gets. Reading each posted frame through
`readBridgeHostMessage` types it and proves the same thing the host's own
suite does: a frame the page would refuse is a frame that never arrives.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the bridge host as a raw request port owner

The boundary ratchet reads a `.sendRequest` access as a call site, and the
host has three: one per arity the page can use. It is not a call site. It
picks no method, reads no reply and decides no acceptance — the page names
the method and runs the typed operation over the client this carries, which
is what the C0 design put page-side so `runRpcOperation` stays unchanged
there. That makes it an owner, beside the socket and relay senders, not a
migration backlog entry.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove a stream that overflows inside subscribe is unsubscribed

A client that emits synchronously from `subscribe` can retire a stream before
its unsubscribe exists to be stored. The identity check that calls it instead
had no test; deleting it left the suite green while the client's stream leaked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the bridge host over in the commit, not after it

A client swap that keeps the session id leaves the handler's own fence inert:
until the passive effect ran, a native frame reached the retiring host and the
client it closed over. A layout effect swaps both inside the commit.

Teardown on unmount now runs while the view is still attached, so a pending
request is answered delivery-unknown instead of being dropped on the floor.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold a refused page frame to one warning per page

A page that sends one bad frame usually sends many, and a line each buries the
first — the one that says why. Same bound the host already keeps on a failing
post, applied per kind and reset when a new page gets a new host.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound the page's terminal viewport at the bridge contract

A viewport crossing the bridge is written into the cached subscribe params of
every stream naming that terminal, including the native terminal screen's, and
the desktop refuses cols over 1000 or rows over 500 when those streams
resubscribe. Unbounded, one page could kill streams it never opened; the frame
is refused instead, and the bound is pinned to the desktop's own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the page's close from latching the bridge host shut

One view carries every document the shell loads, so the page that says `close`
is not the last one. A latched host dropped the next document's `ready` in
silence, and a page that re-sends `ready` on a backoff would retry forever with
nothing posted and nothing logged. Close now cancels what the page owned and
leaves the host live; only dispose shuts it, and a frame arriving after that is
diagnosed rather than dropped.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a throwing client or post inside the bridge host

The `state` frame is sent from inside the client's own state-change fan-out and
a notify runs on the native event handler that delivered the page's frame, so a
synchronous throw from either escapes into a loop the bridge does not own and
takes unrelated listeners with it. Both are fenced and reported once.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove an ack releases the stream's unacked bytes

The frame window reopens on ack through the splice, so deleting the byte
release left every existing test green while a long-lived stream of large
frames would end with overflow on its first frame after an ack.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pass the commit-window harness its children as a prop

`createElement`'s variadic children do not satisfy a props type that declares
`children`, so the file dropped out of the tests typecheck ratchet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): render the harness from the commit-window wrapper, not as children

A props type that declares `children` is what `createElement`'s variadic form
does not satisfy, and passing it as a prop instead trips the react rule. The
wrapper renders the harness itself, which is the parent position the layout
effect ordering needs anyway.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pin the desktop viewport bound by reading it, not importing it

Mobile may not pull an rpc-contract *value* into its bundle, and the boundary
test that enforces that scans this test file too. The pin reads the schema's
own source instead, so drift in either bound still fails loudly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the bridge to one document at a time

A page's `close` now ends that document's turn: until the next `ready`
claims the view, every other frame is dropped and diagnosed instead of
reaching the client, and nothing is posted. Without the fence a straggler
from the closed document was still forwarded, and a `state` frame from the
still-running client landed in the replacement document before its `init`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the request cap against the calls, not the page's ledger

`sendRequest` has no cancel, so a request the page cancelled or closed out
keeps running on the desktop until it answers. The cap now counts those
calls until each settles; counting the pending map let a page interleaving
`close` with batches hold many more than the cap `init` advertises.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 10:46:47 -04:00
Jinwoo Hong 47d107cf2e feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window

`useHostStatusGates` settled the same closed gates for a host that answered
`status.get` with no capabilities and for one whose status nobody could read:
both paths produced an empty capability list and an `ok` verdict. A caller that
walls on a missing capability cannot tell those apart, and the mobile web
bundle's wall is terminal, so it must never fire for the second.

`statusReadable` distinguishes them. `hostProtocolWindow` exposes the two
protocol numbers the hook already read for `evaluateCompat`, as the reply's own
fields, so the bundle wall can evaluate its own window without a second
`status.get`. Both are additive; no existing consumer changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): add the hybrid shell flag and the generation path both loaders demand

`orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same
shape as the terminal autocomplete flag.

`generationDirectoryPath` converts the store's `file://` uri to the absolute
path the native shell view requires: both `MobileWebShellGeneration.load`
implementations refuse anything without a leading slash, and `expo-file-system`
only ever hands out uris.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the hybrid shell session as a pure reducer

Every decision the route makes, as `(session, event) -> (state, effects)`: the
capability wall, the lazy sweep and cache read, the manifest check, the cached
build-id hit that skips paging, the offline open with no compat check, and the
three recovery rules the native shell view's contract states.

Pure, so the rules are table tests rather than a simulator run. Two latches sit
beside the state because both outlive it: `retriedOnce` spans the delete and
refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced
by a `ready` under a new session id.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4)

Wires the four Phase B and A pieces together and adds no decision of its own.
`h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native
`desktop-too-old` wall still applies above it. With the flag off — every store
build, since the only writer is a `__DEV__` Troubleshoot toggle — the route
redirects to `h/[hostId]` and the screen is never constructed, so nothing is
fetched, written or swept.

The runner owns only the impure edges and checks an epoch before every dispatch,
so an unmount, a host change or a retry abandons work in flight and aborts a
download that would otherwise hold four of the host's read slots. The native view
is keyed on the session id, which is what makes the reducer's remount a rebuilt
WebView with every fence reinstalled.

A census test pins who touches the flag: the route reads it, the developer row
reads and writes it, and the key itself lives in one module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the hoisted test doubles instead of asserting them

The changed-code casting gate refuses `as` in new code, and these three were
only widening an empty literal. An annotated `vi.hoisted` factory does the same
job under a check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress

Found on a simulator with the paired desktop stopped: the client never settles
on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting`
-> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping
`reconnecting` to "still connecting" left a phone holding a verified cached
generation on `checking` forever instead of opening it, which is the one case
the offline rule exists for.

`connecting` and `handshaking` are the first dial and still wait; everything
else is unreachable. The mapping moves next to the reducer it feeds, because it
is a decision and the runner is supposed to hold none.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): build the reachability stub instead of asserting it

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move the shell session vocabulary into its own module

Pure move, no behaviour: the states, events, effects and gates the reducer
and its runner share now sit beside the reducer rather than inside it, so
the transition rules have room to grow under the file's line budget.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop a shell effect result whose flow has been superseded

Every restart of the flow bumps a number the effects of that run are stamped
with, and a result echoes it back: a manifest read still in flight when the
socket drops used to reject after the offline path had already opened the
cached generation, replacing a displayed workspace with a download failure,
and a status refetch arriving mid-check used to run the cache read and the
download twice. The gates restart no longer clears the remount latch either;
only the retry button does, so a reconnect cannot grant a second remount.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census the flag across modules, not just src and app

The native view tree was outside the scan, so a reader added there would
have passed an assertion that reads as exhaustive. Proven by adding one to
the shell view module: the census fails.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let only the state that mounted the view hear the view

A native batch reports two failures in a row, and the reducer applied both:
document-load-failed started the delete-and-refetch, render-process-gone
then made it terminal without a new flow, and the cache read the recovery
had already asked for dragged the session back to checking behind a failure
screen. A report arriving outside `ready` is from a view that is no longer
on screen, so it changes nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say a host status could not be read instead of spinning on it

A transient status.get failure settles the gate unreadable and nothing probes
it again, so the route sat on "Checking host" for as long as anyone left it
there and Try again re-read the same settled answer. It now says what
happened and offers no retry, and a status that does become readable picks
the flow back up on its own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): restart the flow on the verdict that changed, not on every gates object

A reconnect cycle rebuilds the gates several times a second with the same
answer in them, and each one re-swept the staging tree and flipped an offline
screen to a spinner and back. Only a changed verdict restarts now, which is
also why the gates effect has to depend on the host id: two hosts whose gates
read identically would otherwise leave the second session in `checking`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): cover what only the shell runner can get wrong

Three cancellations had no test: the epoch that stops a result reaching a
session that is gone, the unmount cleanup that aborts the download, and the
retry that does both before starting over. Each is now red under its own
mutant. The download also re-checks the abort before it writes, since an
abort landing between the fetch's last read and the commit would otherwise
still put a generation on disk for a screen nobody is on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the shell runner's refs after the commit, not during render

React can replay or discard a render, so a handle written during one can run
effects for a session that never existed. The client and the host cache key stop
being refs at all; the effect handle is committed in an effect above every
effect that dispatches.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the hybrid shell flag unreadable outside a development build

Development and release share a bundle id, and the iOS data container survives
an install-over, so a flag a developer toggled on would follow the store build
in and mount the shell on a deep link. The release read never reaches storage.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drive the route's flag read as each build kind reads it

The route test exercises the real preference read, so it has to say which build
it is. A store build whose container kept a development toggle redirects.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let a cache read that lands mid-dial wait for the compat check

A connection still being made is not a host that cannot be reached. Opening the
cached generation there skips the compat check the landing connection is what
makes answerable, so only `unreachable` takes the offline path now.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): move the developer toggle only after its write lands

The route reads the flag back from storage, so a switch that moved on the tap
let the open button race the value that was being persisted. The switch and the
button both stay put until the write settles, and a failed write keeps the
previous position.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): say which build kind a test runs as without asserting on globalThis

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): undo a staged generation the abort reached before the commit

The commit is the write staging cannot take back: it renames into the active
slot and moves the host index. An abort landing while the bytes were being
staged now removes the staged tree instead of activating it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): open the cached workspace when the link, not the bundle, cut a read short

An RPC rejection can reach the reducer before the reachability change does, so
the offline gate never fires and a phone holding a valid generation reads that
the workspace could not be downloaded. A read that failed on the link now opens
what is on disk, the same path offline takes; a verdict about the bundle, from
the host or from the bytes, still fails.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send a hybrid shell recovery through the same gate a start takes

A view failure deleted the host cache and went straight back to the manifest
check on whatever gates the ready session happened to be holding. Gates that
arrive while a generation is on screen are stored without restarting, so after a
reconnect whose status probe failed a ready session carried statusReadable false
and an empty capability list, and the recovery's manifest check walled the host
as bundle-unavailable: terminal, no retry, about a host that never answered.

The gate is now one verdict both entries read, and recovery passes its delete
through it, so an unreadable status lands on the status-unreadable message that
re-arms when a readable gate arrives, and only a readable refusal still walls.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 08:22:25 -04:00