Skip to content

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

Merged
Jinwoo-H merged 48 commits into
mainfrom
ota-c7-7-session-route
Sep 21, 2026
Merged

Jinwoo-H merged 48 commits into
mainfrom
ota-c7-7-session-route

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 36 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2837 $\color{#cf222e}{\Huge{\mathbf{−}}}$​102 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2735
Prod 49 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1972 $\color{#cf222e}{\Huge{\mathbf{−}}}$​395 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1577

Summary

OTA phase C, C7.7: the session screen is registered as a page route, so with the hybrid flag on it renders in the shell from the desktop-served bundle. Built to the C7 design's §1 grant list and rulings 20–32 in the lane's rulings file.

Inert until commit 5 registers anything; released phones unchanged. The route is a flag switch with MobileSessionRouteScreen as its fallback (commit 1) plus a .web.tsx sibling; with the flag off (release default) it renders what main renders, and the session parity family is now rooted at the screen the route mounts (commit 2). No BRIDGE_PROTOCOL_VERSION bump, no new frame kind, nothing under mobile/rpc-foundation/; the page's ready gained an optional accepts array and init an optional storageOversize list, and an older peer on either side ignores both.

Grants, derived by call site, not by hand. Fourteen: navigate, storage, externalLink, haptics, screencastBinary (MobileBrowserPane.tsx, its own census following the haptics shape), native.clipboard.write, native.clipboard.read, native.media.pick|read|release, and the four audio grants native.audio.start|read|stop + native.wakelock.set, which PR D's dictation census named for this route before they were declared. All four or none: three would record with the screen free to lock. PR D's two expectations against an unregistered route are now live rather than vacuous.

A pane request: the reader erases (ruling 34, replacing the tracked handoff of rounds 1–5). A notification tap for a pane of the session already on screen writes paneKey onto the native route. The switch keys the shell without it, so the tap is a tab switch rather than a page reload, and the shell re-sends init for as long as it holds one. The page applies the pane and asks for the param to be erased, naming what it applied; the switch clears it only if that is still the value it holds. Nothing tracks delivery on either side: a lost frame is repaired by the next ask, a clear that never arrived by the next frame that repeats the request, and a newer tap survives an older clear by the comparison. The earlier shape (pending / in-flight / delivered / held route, a delivery promise on onPageReady, onRouteDelivered) drew a new race from each of five bot rounds and is deleted; 76 lines carried one of those names. The clear is one page→shell notify, declared by the shell in init.accepts (the mirror of ready.accepts), so a page never sends it to a shell that did not declare it; no shipped shell serves a page, so there is nothing to negotiate today. Putting paneKey back in the key reds exactly the two pane cases. Round 6 (pullfrog on 7bb9a8b9f9). A frame the page received and then failed to handle is the page's own failure, caught and reported there (inbound-listener-threw), so the shell's post is refused only when no document holds the view and the next document's ready carries the route; on iOS callAsyncJavaScript had turned a listener throw into a rejected post with the document still alive, which the old tracked design would have retried into the same throw. The write-path census gained the last-visited row and now counts the note-first callers the mirror module holds to one. sendInit's doc no longer describes an awaited post.

Storage. Nine exact keys and two workspace-scoped prefixes join the page allowlist, scoped one level deeper than C2.9's: the chat-tab and terminal-input keys are built as <prefix><enc(hostId)>:<enc(worktreeId)>, so pageStorageKeysForRoute / isPageStorageKeyForRoute read host and workspace off the route pathname. orca:hostDockWidth and orca:hostSidebarWidth are included: preferences the native screen reads, which the page may not lose. An unsent-send journal over the init frame's value bound (measured: 48 unsettled sends at 342 chars each exceed it, 47 do not) is dropped from init and named rather than refusing the whole frame, since the schema refines on the bound; native storage keeps the journal. An oversize value rejects with PageStorageRefusedError, AsyncStorage's own contract, which the send composer shows as "Message not sent"; a key the route may not write and a write the shell would not take are dropped and logged (ruling 33.4), because every page-closure writer of an unlisted key awaits setItem with no catch. A key dropped from init for size is no longer writable by that page session (ruling 33.6): the shell names it in storageOversize and a page write rejects, so a send is refused rather than replacing the device's journal with a one-entry value; the shell enforces the same refusal on its own write path, so a page served from an older desktop bundle that ignores the field is refused too. One write path for every mirrored key (ruling 35). Every mirrored key is written through one path, which notes the shell's mirror from what the store holds after the write rather than from what it was handed. A refused page write therefore changes nothing, and the two hand-written rollbacks are gone with the notes that needed them: fourteen call sites in six files noted the mirror before persisting and twelve never looked again. The shell's own write of a value the page has already applied keeps its note-first order, because the device store refuses nothing and the next init is built in the same turn. A batch write is one call with one answer: it applies what it can up to the first pair it cannot take, and stops there. A census (mobile-mirrored-storage-write-path.test.mjs) reds any writer that bypasses the path. Known property: on the device the mirror trails the store by the write's round trip, so an init built inside those milliseconds would be one write behind; native writers live on native screens and a page mount is a navigation, so no same-tick path reaches it. oversizeKeys stays on the page: a write is a one-way post, so the shell's own refusal never reaches the page, and the composer needs the rejection to say "Message not sent".

Pinned on this head (tests in this PR).

measure before this head
session closure, modules / local 4,323 / 981 (PR D's pin) 4,330 / 988
hop-census rows 16 23 (all seven X → session; none outbound, asserted as grant coverage)
bundle chunks (ceiling 76 at 15 route keys; the fifteenth key is C8's catch-all) 67 67

Two defects found by the render check. use-mobile-session-markdown-actions.ts registered BackHandler unguarded on web and re-registered on every dirty-draft change; gated on Platform.OS like the three sibling sites, with a unit test like theirs (the hook now declares the fourteen fields it reads instead of taking all 268 of the session model). use-mobile-session-diff-comments.ts runs void loadDiffComments() with no catch, so a rejected worktree.show is an unhandled rejection on every session mount. The one-line fix moves a certified golden effect, so this PR keeps the behaviour and pins the page's error list to exactly that entry (mobile-web-app-session-render.test.mjs), which reds when the rejection stops; the fix is a follow-up with a re-record.

Also. MobileSessionHeader.tsx and QuickCommandsSheet.tsx gained accessibilityRole="button" (Back census); this is a native behaviour change as well as a page one, VoiceOver now announces both as buttons. Every one of the fourteen grants is derived by a named census: removing any one reds a case that names it (mobile-web-app-page-grant-call-sites.test.mjs for navigate, storage, externalLink, both clipboard grants and the media three; the haptics, screencast and dictation censuses for the rest). Two page routes (/h/[hostId], agent-history) reach openExternalLink through the shared layout's protocol wall without declaring externalLink; pre-existing, recorded as KNOWN_UNDECLARED, its own PR. Two modules came out at their own boundaries rather than a line-cap bump, bridge-init-route.ts and bridge-host-route.ts. mobile-web-app-route-chunk-closure.mjs looked routes up by exact path and read "no output" for the first route with a .web.tsx sibling. The render check does not paint a terminal and says so; mobile-web-app-terminal-render.test.mjs is the terminal's browser proof.

Commits

sha subject
06dd168620 feat: switch the session route to the shell, still unregistered
e5692406a7 test: root the session parity family at the screen the route mounts
57370a36c6 feat: give the page the session screen's stored preferences
185914430d feat: register the session page route
c41dcdcabf test: render-check the session route, and quiet the two things it found
c5b85dea20 test: refresh the session parity pins for the two seam edits
5fe8cfe1b3 fix: report the diff-notes rejection instead of catching it
9e80799719 test: repin the session effect hash after the diff-notes revert
beda1cc384 merge of origin/main (a91ca8b19e): one conflict in the session closure pin, re-measured on the merge
52628392be fix: reject a page storage write for size only, and log the rest (round 1, ruling 33.4)
a9f3ac771d docs: make every count in the session route say the same number (round 1: fourteen grants, nine exact keys, 343 chars per journal entry in the array)
40617a6187 merge of origin/main (5d51bcaf6e, C8): flag-census conflict, both registrations kept; firstParam repointed
f2a8804866, 04eee129ef test + feat: the pane update over a re-sent init (ruling 33.1)
d23e9a8acb test: the session switch's ten cases and the BackHandler gate (ruling 33.2)
65e34b37c9, b1fe293781 test: call-site grant censuses, each red attributable (ruling 33.3)
9a85751794 fix: the pane hook waits in a ref (react-doctor)
5a22ec7827 test: closure re-pin and the .web.ts overrides row
9423b39f24 fix: keep the page off a journal init could not carry (ruling 33.6)
1294656b6d merge of origin/main (4aaa6c7fb4), no conflicts
8b4c559e90 fix: deliver a route update only when a param moved, and only after init went out (round 2)
768aa8ddb9 docs: attach the init-storage doc to its type, keep the overrides escape (round 2)
b19983779f fix: catch the custom-key save the page store refuses (round 2)
81816833a2 fix: one rejection per refused batch, never an orphaned one (bots)
c03d5e6b15 fix: record a delivered route key only after a frame goes out (bots)
3e43a2dbb7 fix: the shell refuses a write to a key it dropped from init for size, so an older page cannot clobber the journal (bots; ruling 33.6 amended)
e2565405cc test: the page half of the pane hook (bots)
47eb9f1fe4 fix: roll the custom-keys mirror back when the store refuses (bots)
553ba041ed fix: report a route delivered only after its frame was posted; bridge-host-frames.ts split out at the line cap (bots)
b27bf7959a docs: the two duplicated JSDoc blocks (bots)
e89ab25d46 fix: let the host own a pending route until its frame lands (bots: no retry after a refused first post; a re-render inside the post window stranded the delivery)
fba8cb3f3d fix: roll the journal mirror back when persistence fails (bots)
8b81dd37b2 feat: the shell accepts a page's clear of the route param it was handed; declared in init.accepts (ruling 34)
ec2b8d5fa8 refactor: re-send init on a route change and track nothing else; pending/held/delivered/in-flight, retryPendingRoute, the delivery promise and onRouteDelivered deleted (ruling 34)
9d8920470a feat: the page applies the pane from init and asks for the param to be erased, naming what it applied (ruling 34)
ba5ecae8f6 fix: the split's fallout; bridge-notify-envelope.ts and bridge-frame-fields.ts out of bridge-envelope.ts at the cap, an esbuild cycle that read as a TypeError in twelve render files
be582e0c54 test: read posted frames with the page's own reader (quality gate refused a cast)
2af0764290 refactor: every mirrored write on one path that notes what the store took (ruling 35)
83d63cee1d refactor: answer a page batch write at the first pair it cannot take (ruling 35)
a72a5b80f0 style: format the write-path census and the journal writer
7bb9a8b9f9 merge of origin/main (f07bf8544c), no conflicts; closure pin unchanged at 4,330 / 988
9508e25e3e test: the write-path census gains the last-visited row and counts writeMirroredStorage callers (round 6)
6e4dbaf03c docs: sendInit describes the fire-and-forget it is (round 6)
517037aac8 fix: receive catches a listener throw and reports it, so a received frame never rejects the host's post; route-seam comments name the real repair (round 6, ruling 34 addendum)
7955ee6e5a refactor: held session out of bridge-rpc-client.ts at the line cap (277 counted after)

Verification

Lead gate on beda1cc384 against a91ca8b19e: all lint gates 0; config/scripts 246 files / 2,230 passed; mobile 785 / 8,770 passed. Lead gate on 1294656b6d against 4aaa6c7fb4: all lint gates 0; config/scripts 247 files / 2,254 passed; mobile 798 / 8,866 passed. Lead gate on e2565405cc against 4aaa6c7fb4: all lint gates 0; config/scripts 247 / 2,254; mobile 800 / 8,881. Lead gate on b27bf7959a against 4aaa6c7fb4: all lint gates 0; config/scripts 247 / 2,254; mobile 800 / 8,883. Lead gate on fba8cb3f3d against 4aaa6c7fb4: tsc, ratchet, oxlint, format, anti-slop, both changed-code gates, react-doctor all 0; config/scripts 247 files / 2,254 passed; mobile 800 / 8,888 passed; no machine paths.
Lead gate on 7bb9a8b9f9 against f07bf8544c: tsc, ratchet, oxlint, format, anti-slop, both changed-code gates, react-doctor all 0; config/scripts 248 files / 2,260 passed; mobile 801 / 8,897 passed; no machine paths.
Lead gate on 7955ee6e5a against f07bf8544c: all lint gates 0; config/scripts 248 files / 2,262 passed; mobile 801 / 8,899 passed; no machine paths.

…ed (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
…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
…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
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
…s 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
…(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
…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
…rt (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
Brings in C7.10 PR D (a91ca8b, dictation over the native audio verbs) and four unrelated
commits: the preview-rig readiness fix, the frame-budget noise floor, the terminal-pane hydration
fix and the node-pty MSYS guard. Main's rig mechanics in `config/scripts` are taken wholesale.

One conflict, `mobile-web-app-session-terminal-closure.test.mjs`: both sides rewrote the module
docstring and the count. Resolved by keeping both accounts — item D's -5 / +3 for the capture seam
and C7.7's +1 for the route body — and re-measuring the total on the merge rather than summing
either. 4,324 modules and 982 local here, against the 4,323 / 981 item D pinned on its own base;
the +1 is `MobileSessionRouteScreen.tsx`, read out of `ROUTE_ENTRY` by name. `ROUTE_ENTRY` and the
route-entry case are kept; the note says why neither side's arithmetic survives the other.

`web-entry/web-overrides.json` auto-merged: 34 entries, both the session sibling and item D's
`dictation-capture.web.ts`, no duplicate file. The manifest, the hop census and the haptics census
had no incoming edit.

The session route declares the four audio grants, taken from item D's own census rather than by
hand: `mobile-web-app-session-dictation-capture.test.mjs` named
`/h/[hostId]/session/[worktreeId] needs native.audio.start|read|stop` and `native.wakelock.set`
before they were added. All four or none — a route granted three records with the screen free to
lock, and a lock mid-processing loses the transcript. Fourteen grants now. Ruling 4's degradation
is retired with them: the page no longer falls back to the vendored module's denied microphone.

Item D wrote two expectations against an unregistered session route and both are now live rather
than vacuous: `finds the seam in exactly one closure` pins that one route instead of none, and the
route-module list gains the session entry. Its control case stays, because a green rule over a
satisfied manifest cannot show the rule failing.

Re-measured on the merge: hops 23, chunks 65 against the 72 the fence allows at 14 route keys,
bundle 8,023,415 bytes across 108 assets. Every postinstall generator re-run; no resurrected
deletion and no duplicated top-level declaration.

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

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The pull request registers the mobile web session route and adds native and web route entry points. It adds browser render, closure, grant, hop, and manifest coverage. Page storage is scoped to host and route, with workspace-specific keys, size limits, and explicit refusal errors. Native storage writes now update the mirrored layer before persistence. Session journal failures reject sends without dispatching requests. Back controls receive explicit button accessibility roles.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to fba8c

Enabled launches can briefly start native session subscriptions, while pane navigation can remain pending after racing with initialization. Resolve both lifecycle issues before merging.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.79% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 112 functions across 70 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description provides extensive technical context, implementation details, objectives, commits, and verification results, but it does not follow the required template. The required Linked Issue is … Complete the required description template. Add a valid issue reference after "Fixes #", provide visual proof or write "N/A" with a reason, complete the Testing and Checklist items, and add the missing ELI5, Review, Notes, and other require…
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary change: registering the mobile session page route for OTA phase C, C7.7.
Full details: Description check

Explanation

The description provides extensive technical context, implementation details, objectives, commits, and verification results, but it does not follow the required template. The required Linked Issue is blank, Visual Proof is missing, Testing checkboxes are not completed, and several required sections such as ELI5, Review, Notes, and Checklist are absent.

Resolution

Complete the required description template. Add a valid issue reference after "Fixes #", provide visual proof or write "N/A" with a reason, complete the Testing and Checklist items, and add the missing ELI5, Review, Notes, and other required sections.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 3f2f41ef-2557-4f6f-a5f7-e4af07646b50

📥 Commits

Reviewing files that changed from the base of the PR and between a91ca8b and beda1cc.

📒 Files selected for processing (40)
  • config/scripts/mobile-web-app-haptics-seam.test.mjs
  • config/scripts/mobile-web-app-page-closure-families.test.mjs
  • config/scripts/mobile-web-app-page-route-modules.mjs
  • config/scripts/mobile-web-app-route-chunk-closure.mjs
  • config/scripts/mobile-web-app-screencast-lane-grant.test.mjs
  • config/scripts/mobile-web-app-session-dictation-capture.test.mjs
  • config/scripts/mobile-web-app-session-render.test.mjs
  • config/scripts/mobile-web-app-session-terminal-closure.test.mjs
  • config/scripts/mobile-web-page-route-hop-coverage.test.mjs
  • config/scripts/mobile-web-page-routes.mjs
  • config/scripts/mobile-web-page-routes.test.mjs
  • mobile/app/h/[hostId]/session/[worktreeId].tsx
  • mobile/app/h/[hostId]/session/[worktreeId].web.tsx
  • mobile/src/components/CustomKeyModal.tsx
  • mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
  • mobile/src/mobile-web-shell/bridge-host.ts
  • mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts
  • mobile/src/mobile-web-shell/bridge/page-async-storage.ts
  • mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts
  • mobile/src/mobile-web-shell/page-served-back-control-a11y.test.ts
  • mobile/src/mobile-web-shell/page-storage-keys.test.ts
  • mobile/src/mobile-web-shell/page-storage-keys.ts
  • mobile/src/mobile-web-shell/shell-screen-route-census.test.ts
  • mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx
  • mobile/src/mobile-web-shell/use-page-host-snapshot.ts
  • mobile/src/session/MobileSessionHeader.tsx
  • mobile/src/session/MobileSessionRouteScreen.tsx
  • mobile/src/session/QuickCommandsSheet.tsx
  • mobile/src/session/mobile-session-route-parity.test.ts
  • mobile/src/session/mobile-session-route-source-family.test-support.ts
  • mobile/src/session/mobile-structured-send-operation-journal.ts
  • mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts
  • mobile/src/session/use-mobile-session-diff-comments.ts
  • mobile/src/session/use-mobile-session-markdown-actions.ts
  • mobile/src/session/use-mobile-structured-agent-session-send.test.tsx
  • mobile/src/storage/preferences.ts
  • mobile/src/storage/session-view-preferences.ts
  • mobile/src/terminal/terminal-accessory-layout.ts
  • mobile/web-entry/index.tsx
  • mobile/web-entry/web-overrides.json

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.

})
: null

if (enabled !== true || !hostId || route === null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not mount the native session while the flag is unresolved.

useMobileWebShellEnabled() starts as null and resolves in an effect. Line 59 treats that state as disabled, so an enabled launch first mounts MobileSessionRouteScreen. Its controller opens terminal, chat, and tab subscriptions before the shell replaces it.

Handle null separately. Render a neutral loading state until the flag resolves.

Proposed fix
+  if (enabled === null) {
+    return null
+  }
   if (enabled !== true || !hostId || route === null) {
     return native
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (enabled !== true || !hostId || route === null) {
if (enabled === null) {
return null
}
if (enabled !== true || !hostId || route === null) {

Comment on lines 60 to 71
)

useEffect(() => {
// No catch, deliberately, and it is a recorded defect rather than an oversight: a *refused*
// `worktree.show` returns above, and a *rejected* one is an unhandled rejection on every mount
// — visible in the page as a document-level error, measured by
// `config/scripts/mobile-web-app-session-render.test.mjs`. Adding `.catch` here is the fix and
// it moves a golden: `matrix-session.diff-notes-worktree.show-1` certifies the rejection as an
// effect of the loaded checkpoint, so the change is a re-record and a review event, not a line.
void loadDiffComments()
}, [loadDiffComments])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,130p' mobile/src/session/use-mobile-session-diff-comments.ts
rg -n "loadDiffComments|KNOWN_UNCAUGHT|diff-notes-worktree.show-1|pageerror" config/scripts/mobile-web-app-session-render.test.mjs mobile/src/session

Repository: stablyai/orca

Length of output: 6549


🏁 Script executed:

set -u
printf '%s\n' '--- render test focused sections ---'
sed -n '1,90p' config/scripts/mobile-web-app-session-render.test.mjs
sed -n '130,235p' config/scripts/mobile-web-app-session-render.test.mjs
sed -n '235,290p' config/scripts/mobile-web-app-session-render.test.mjs
printf '%s\n' '--- operation definitions and route references ---'
rg -n -C 4 "sessionWorktreeRecordRead|worktree.show|useMobileSessionDiffComments|mobile.*session|session route|browser-served" mobile src config --glob '!**/node_modules/**'
printf '%s\n' '--- relevant operation files ---'
fd -i 'mobile-session-read-operations|mobile-session-route|rpc' mobile/src/session src | head -80

Repository: stablyai/orca

Length of output: 45532


🏁 Script executed:

set -u
printf '%s\n' '--- render-check rejection path ---'
rg -n -C 12 "KNOWN_UNCAUGHT|RenderCheckShellDouble|page\.on\('pageerror'|pageerror|answers no RPC|diff-notes-worktree" config/scripts/mobile-web-app-session-render.test.mjs
printf '%s\n' '--- read-operation binding ---'
cat -n mobile/src/session/mobile-session-read-operations.ts
printf '%s\n' '--- route and hook binding ---'
rg -n -C 12 "useMobileSessionDiffComments|MobileSessionDiffComments|sessionWorktreeRecordRead|route.*session|SESSION_ROUTE" mobile/src/session/mobile-session-route.ts mobile/src/session --glob '*.tsx' --glob '*.ts'
printf '%s\n' '--- request contract and comparable catches ---'
rg -n -C 8 "async request|request\(client|class .*Rpc|create.*Operation|interpretOrThrowRefusalMessage|\.catch\(" mobile/src/transport mobile/src/session src/shared --glob '*.ts' --glob '*.tsx' | head -500

Repository: stablyai/orca

Length of output: 42482


🏁 Script executed:

set -u
printf '%s\n' '--- render-check rejection path ---'
rg -n -C 12 "KNOWN_UNCAUGHT|RenderCheckShellDouble|page\.on\('pageerror'|pageerror|answers no RPC|diff-notes-worktree" config/scripts/mobile-web-app-session-render.test.mjs
printf '%s\n' '--- read-operation binding ---'
cat -n mobile/src/session/mobile-session-read-operations.ts
printf '%s\n' '--- route and hook binding ---'
rg -n -C 12 "useMobileSessionDiffComments|MobileSessionDiffComments|sessionWorktreeRecordRead|route.*session|SESSION_ROUTE" mobile/src/session/mobile-session-route.ts mobile/src/session --glob '*.tsx' --glob '*.ts'
printf '%s\n' '--- request contract and comparable catches ---'
rg -n -C 8 "async request|request\(client|interpretOrThrowRefusalMessage|\.catch\(" mobile/src/transport mobile/src/session src/shared --glob '*.ts' --glob '*.tsx' | head -500

Repository: stablyai/orca

Length of output: 41949


Handle rejected loadDiffComments promises in the mount effect. The session controller mounts this hook on the browser-served session route. When sessionWorktreeRecordRead.request rejects, loadDiffComments rejects before the refusal branch, and void loadDiffComments() leaves the rejection unhandled. The browser reports a pageerror, which the render check records as RenderCheckShellDouble: the render check answers no RPC. Attach an effect-local .catch and handle the load failure through the existing UI path.

…est (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
…er (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
…d 1)

Two resolutions, both keeping main's mechanics:

- `mobile-web-shell-flag-census.test.ts`: `SWITCHED_ROUTES` keeps both
  registrations, C8's catch-all beside C7.7's session route.
- The session route switch reads `firstParam` from
  `src/navigation/route-param-reader.ts`, where C8 moved it out of
  `mobile-source-control-screen-state`.

Re-measured on the merge rather than summed: the session closure reads
4,324 modules / 982 local, unchanged from the pin; the hop census reads
23 rows. The whole bundle now emits 67 chunks across 15 route keys
(C8's catch-all is the fifteenth), against a ceiling of 76.

All five postinstall generators rerun; no resurrected deleted file, no
duplicated top-level function, mobile tsc 0.

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

One storage path can lose the native durable send journal. When the journal exceeds the page's value bound, init drops it but the page keeps write access to the key, so the next send replaces the native journal with a one-entry value. Details inline.

Reviewed changes

  • Session route registered as a page switch. [worktreeId].tsx becomes the review switch's shape, with MobileSessionRouteScreen as the built-not-mounted native fallback and a .web.tsx sibling for the page.
  • Fourteen grants added. The session route is the first to need the media, audio, wake-lock and screencast-lane tokens; mobile-web-page-route-hop-coverage.test.mjs asserts every other route's grants are a subset.
  • Storage allowlist widened and scoped. Ten exact keys join two workspace-scoped prefixes; pageRouteWorkspace reads host/worktree off the pathname, and pageStorageEntriesForInit bounds values/entries so one over-cap value cannot take the whole init frame down.
  • Refusals became rejections. page-async-storage now rejects with PageStorageRefusedError, which the durable send journal catches as "Message not sent".
  • Mirror writers wired. The new allowlisted keys' writers call noteMirroredWrite so the synchronous init reads current values.
  • Seam fixes found by the new render check. BackHandler is gated on Platform.OS, and the session/diff Back controls gain accessibilityRole="button".
  • Censuses and pins updated in lockstep (flag census, back-control a11y, hop coverage, dictation, terminal closure, route-module map).

ℹ️ The page still ships one document-level error on every session mount

use-mobile-session-diff-comments.ts runs void loadDiffComments() with no catch, so a rejected worktree.show is an unhandled rejection, and mobile-web-app-session-render.test.mjs pins the page's error list to exactly that entry — meaning the test reds when the fix lands. The PR is explicit that the fix is a golden re-record deferred to a follow-up; the ask is only that the follow-up is tracked, since until then every session mount in the page produces a document-level error that will read as noise in crash reports and the device proof.

ℹ️ Nitpicks

  • mobile-web-page-route-hop-coverage.test.mjs:57 also says "ten grants"; the manifest declares 14 (the four audio verbs are included).
  • MobileSessionActiveContent.tsx:199 (comment) and terminal-viewport-refit.test.ts:160 (test name) still say SessionScreen, which this PR renamed to MobileSessionRouteScreen.
  • use-mobile-dock-resize.ts:74,77 and MobileSessionActiveContent.tsx:217 void-call the storage savers with no catch. Now that a page write can reject, a refusal there (port failure during a dock drag, for example) becomes an unhandled rejection; the other page-closure writers this PR touched added .catch.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…hase 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
…nt 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
… 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
…A 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
…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
…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
…ling (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
…ase 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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 28e91dd7-8b02-4496-9a06-202ab98bd8c5

📥 Commits

Reviewing files that changed from the base of the PR and between beda1cc and 1294656.

📒 Files selected for processing (45)
  • config/scripts/mobile-web-app-page-grant-call-sites.mjs
  • config/scripts/mobile-web-app-page-grant-call-sites.test.mjs
  • config/scripts/mobile-web-app-session-terminal-closure.test.mjs
  • config/scripts/mobile-web-page-route-hop-coverage.test.mjs
  • config/scripts/mobile-web-page-routes.mjs
  • mobile/app/h/[hostId]/session/[worktreeId].tsx
  • mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
  • mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
  • mobile/src/mobile-web-shell/bridge-host-contract.ts
  • mobile/src/mobile-web-shell/bridge-host-init.test.ts
  • mobile/src/mobile-web-shell/bridge-host-route.ts
  • mobile/src/mobile-web-shell/bridge-host-test-harness.ts
  • mobile/src/mobile-web-shell/bridge-host.ts
  • mobile/src/mobile-web-shell/bridge/bridge-caps.ts
  • mobile/src/mobile-web-shell/bridge/bridge-client-session.ts
  • mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
  • mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts
  • mobile/src/mobile-web-shell/bridge/bridge-init-route.ts
  • mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.test.ts
  • mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
  • mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts
  • mobile/src/mobile-web-shell/bridge/bridge-route-update.ts
  • mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
  • mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
  • mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts
  • mobile/src/mobile-web-shell/bridge/page-async-storage.ts
  • mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx
  • mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts
  • mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx
  • mobile/src/mobile-web-shell/page-storage-keys.test.ts
  • mobile/src/mobile-web-shell/page-storage-keys.ts
  • mobile/src/mobile-web-shell/shell-screen-route-census.test.ts
  • mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
  • mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
  • mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx
  • mobile/src/mobile-web-shell/use-page-host-snapshot.ts
  • mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts
  • mobile/src/session/notification-pane-tab.ts
  • mobile/src/session/session-markdown-hardware-back.test.ts
  • mobile/src/session/use-mobile-session-markdown-actions.ts
  • mobile/src/session/use-notification-pane-navigation.test.tsx
  • mobile/src/session/use-notification-pane-navigation.ts
  • mobile/src/session/use-notification-pane-navigation.web.ts
  • mobile/web-entry/index.tsx
  • mobile/web-entry/web-overrides.json

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

… 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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — minor suggestions inline.

Reviewed changes

Range beda1cc384..1294656b6d — the round-1 response to the prior review, plus two merges of main.

  • A pane request rides a re-sent init (ruling 33.1). paneKey leaves the session switch's key, the host holds the moved route and sends a second init only to a page that declared accepts: ['route-update'], and the native param is cleared once the page has been handed it.
  • An over-cap stored key is named, not just dropped (ruling 33.6). init's storageOversize list makes the page refuse its own write, so the durable send journal is no longer replaced by a one-entry value.
  • Refusals narrowed to too-large (ruling 33.4). A key outside the route and a write the shell will not take are logged drops now, so page-closure writers with no catch stay quiet.
  • A call-site grant census (ruling 33.3). mobile-web-app-page-grant-call-sites.{mjs,test.mjs} derives the six grants that only a copied list held, with a named case per row.
  • Modules split out for their own line caps: bridge-init-route.ts, bridge-host-route.ts, bridge-route-update.ts, notification-pane-tab.ts.
  • Tests and pins updated in lockstep: the session switch's ten cases, the BackHandler gate, the pane wire cases, and the closure/hop-count re-pins.

ℹ️ The over-cap journal fix only holds when the shell and the page are the same build

storageOversize is computed in the native shell and only the new page's accept honours it. A page served from a desktop bundle older than the app ignores the field and still writes its one-entry journal over the device's — the clobber ruling 33.6 exists to stop. The shell already has the list on the init path, so refusing the write in the host's storage notify path would cover an old page too. Is the page assumed to move with the app, or is this a rollout gap?

Technical details
# An over-cap storage write is refused only by the new page

## Affected sites
- `mobile/src/mobile-web-shell/bridge/page-async-storage.ts:96-119` — `accept` refuses a key in `oversizeKeys`, set only from the `init` frame's `storageOversize`.
- `mobile/src/mobile-web-shell/bridge-host.ts:276-287` — the host's `storage` notify checks `isPageStorageKeyForRoute` only, not the oversize set, so a page that ignored the field posts its replacement and the host applies it.
- `mobile/src/mobile-web-shell/use-page-host-snapshot.ts:102-131` — `readStorage` already computes the oversize names; `writeStorage` does not consult them.

## Required outcome
- Decide whether an old page under a new shell must be protected. If so, the host refuses a write to a key it could not carry — it already computes the same set through `pageStorageEntriesForInit` — which closes the clobber regardless of page build.

## Open questions for the human
- Is the desktop-served page guaranteed to match the app's build at rollout? If not, is a native-side refusal worth the small extra check?

ℹ️ Nitpicks

  • mobile/src/session/use-notification-pane-navigation.web.ts is the page half of ruling 33.1 and has no test — the new use-notification-pane-navigation.test.tsx mounts the native .ts, and bridge-route-update.test.ts stops at the client, so a regression in the seed/counter/consume logic would leave both green.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…ides 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
…ase 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
…r (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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Restore the mirrored journal value when… · mobile-structured-send-operation-journal.ts:91-99

mobile/src/session/mobile-structured-send-operation-journal.ts:91-99
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Restore the mirrored journal value when persistence fails. writeEntries updates the mirror before AsyncStorage.setItem or removeItem. If that operation rejects, a later route init can send the unpersisted journal value to the page. The native send caller reports Message not sent and returns rejected before dispatch, so this path does not retry or mark the send as accepted. Restore the previous mirror value in the persistence error path.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 919085a2-e874-451e-8c4e-72c2018613fe

📥 Commits

Reviewing files that changed from the base of the PR and between e256540 and b27bf79.

📒 Files selected for processing (12)
  • mobile/src/components/CustomKeyModal.tsx
  • mobile/src/components/custom-key-modal-save-refusal.test.tsx
  • mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
  • mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
  • mobile/src/mobile-web-shell/bridge-host-contract.ts
  • mobile/src/mobile-web-shell/bridge-host-frames.ts
  • mobile/src/mobile-web-shell/bridge-host-init.test.ts
  • mobile/src/mobile-web-shell/bridge-host-route.ts
  • mobile/src/mobile-web-shell/bridge-host-test-harness.ts
  • mobile/src/mobile-web-shell/bridge-host.ts
  • mobile/src/mobile-web-shell/bridge/page-async-storage.ts
  • mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
💤 Files with no reviewable changes (1)
  • mobile/src/mobile-web-shell/bridge/page-async-storage.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • mobile/src/components/CustomKeyModal.tsx
  • mobile/src/components/custom-key-modal-save-refusal.test.tsx

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one route-delivery race to consider, plus two doc leftovers.

Reviewed changes

Range e2565405cc..b27bf7959a — the round-3 commits on top of the last pullfrog review.

  • A refused custom-key save is rolled back. saveCustomKeys reads the mirrored value, notes the write, and on an AsyncStorage.setItem rejection restores the previous note before rethrowing, so a store refusal no longer leaves the key in the map the next init reads.
  • Route delivery now awaits the post. sendInit/publishRoute return Promise<boolean>, onPageReady hands the screen that promise, and the screen spends a one-shot paneKey only once the frame actually landed — closing the "delivered before post resolved" gap. The send half moved into new bridge-host-frames.ts.
  • Two duplicated JSDoc blocks folded back in page-async-storage.ts and bridge-host.ts.
  • Tests added for the refused-init delivery, the view-refused init, and the custom-key rollback.

ℹ️ Nitpicks

  • mobile/src/mobile-web-shell/MobileWebShellScreen.tsx:276-279 — the old "Recorded only once a frame has gone out" comment still sits above the new "Attempted now, recorded only once the frame has reached the page" one, and now contradicts attemptedRouteKey.current = key at line 283.
  • mobile/src/mobile-web-shell/bridge-host.ts:311 — the folded comment reads "Carrying whether an the frame reached the page"; the leftover an (from the old "whether an init went out") should go.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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
`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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 1531b4ce-5a34-4877-a1fc-53859967a8eb

📥 Commits

Reviewing files that changed from the base of the PR and between b27bf79 and fba8cb3.

📒 Files selected for processing (13)
  • mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
  • mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
  • mobile/src/mobile-web-shell/bridge-host-contract.ts
  • mobile/src/mobile-web-shell/bridge-host-init.test.ts
  • mobile/src/mobile-web-shell/bridge-host-route.ts
  • mobile/src/mobile-web-shell/bridge-host-test-harness.ts
  • mobile/src/mobile-web-shell/bridge-host.ts
  • mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
  • mobile/src/mobile-web-shell/bridge/bridge-route-update.ts
  • mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
  • mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
  • mobile/src/session/mobile-structured-send-operation-journal.test.ts
  • mobile/src/session/mobile-structured-send-operation-journal.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
  • mobile/src/mobile-web-shell/bridge-host.ts
  • mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
  • mobile/src/mobile-web-shell/bridge/bridge-route-update.ts
  • mobile/src/mobile-web-shell/bridge-host-contract.ts
  • mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
  • mobile/src/mobile-web-shell/MobileWebShellScreen.tsx

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment on lines +74 to +80
if (update.kind === 'hold' || inFlight) {
return
}
inFlight = true
void args.sendInit().finally(() => {
inFlight = false
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,150p' mobile/src/mobile-web-shell/bridge-host-route.ts
rg -n 'createBridgeHostRoute|\.landed\(|\.retry\(|onRouteDelivered|sendInit' mobile/src/mobile-web-shell

Repository: stablyai/orca

Length of output: 9356


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- bridge-host.ts ---'
sed -n '1,210p' mobile/src/mobile-web-shell/bridge-host.ts
sed -n '280,430p' mobile/src/mobile-web-shell/bridge-host.ts
printf '%s\n' '--- route tests / harness references ---'
rg -n -C 5 'createBridgeHostRoute|publish\(|landed\(|retry\(|route.*in.flight|inFlight|sendInit|onRouteDelivered|ready|tap|handle' mobile/src/mobile-web-shell/*test* mobile/src/mobile-web-shell/bridge-host* mobile/src/mobile-web-shell/bridge-host-route.ts
printf '%s\n' '--- route update implementation ---'
sed -n '1,220p' mobile/src/mobile-web-shell/bridge/bridge-route-update.ts

Repository: stablyai/orca

Length of output: 45539


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions

Length of output: 18235


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- bridge-host.ts 60-190 ---'
sed -n '60,190p' mobile/src/mobile-web-shell/bridge-host.ts
printf '%s\n' '--- bridge-host.ts 300-415 ---'
sed -n '300,415p' mobile/src/mobile-web-shell/bridge-host.ts
printf '%s\n' '--- route-related test files ---'
find mobile/src/mobile-web-shell -maxdepth 1 -type f \( -name '*route*.test.*' -o -name 'bridge-host*.test.*' \) -print
printf '%s\n' '--- exact route assertions and route-manager tests ---'
rg -n -C 8 'routeDeliveries|routeRefusals|onRouteDelivered|publish|notification|tap|route-update|landed|retry|in flight|in-flight|pending' \
  mobile/src/mobile-web-shell/bridge-host-route.ts \
  mobile/src/mobile-web-shell/bridge-host*.test.ts \
  mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx \
  mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx \
  mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts

Repository: stablyai/orca

Length of output: 42164


Retry the held route when the in-flight init completes.

When route A is in flight and publish stores route B, attempt returns because inFlight is true. When A lands, landed records only A, and the finally callback only clears inFlight. No callback attempts B, so B can remain pending until a later ready, handle, or tap.

After the post succeeds, retry the held route when it still differs from delivered.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one route-delivery race to consider, plus a stale doc block.

Reviewed changes

Range b27bf7959a..fba8cb3f3d — the two commits on top of the last pullfrog review.

  • The host now owns a pending route. createBridgeHostRoute tracks delivered separately from the held route, measures movement against delivered (so a refused frame and a repeat tap still read as moved), and reports delivery through onRouteDelivered only once sendInit's post resolves; publish/retry return nothing and onPageReady reverts to () => void.
  • A stranded route gets retried. retryPendingRoute fires when the view handle is regained, and MobileWebShellScreen's effect no longer tracks the attempt itself.
  • The send-journal mirror is rolled back on a refused persist. writeEntries restores the prior mirrored value when AsyncStorage rejects, so init cannot publish a journal the device never wrote.
  • Tests added for the regained-handle delivery, the same-route re-render during an in-flight post, the repeat tap after a refused frame, and the journal rollback.

ℹ️ Nitpicks

  • mobile/src/mobile-web-shell/bridge-host-contract.ts:181-182 — a second JSDoc block sits above onPageReady and still says "delivered settles true once the init … reached the page", a parameter this delta removed (onPageReady: () => void). The :176-180 block already documents the field; the second is a leftover from the round-2 signature.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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
…y 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
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
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
…ried 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
…t 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
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
…t 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
…ot 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
No conflicts: nothing on main touched the bridge seam, the mirrored storage path or the page
route closure this branch changes.

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — minor suggestions inline.

Reviewed changes

Range fba8cb3f3d..7bb9a8b9f9 — ruling 34 (the reader erases its own route request) and ruling 35 (one write path for every mirrored key), plus the envelope splits they required.

  • Route delivery tracking deleted (ruling 34). createBridgeHostRoute holds only the route now; attempt/landed/inFlight/retryPendingRoute/onRouteDelivered and the session switch's clearPaneKey are gone, replaced by a page→shell route-param-clear notify declared in init.accepts.
  • The page applies a pane and erases the request it came on. use-notification-pane-navigation.web.ts seeds from the opening init, re-asks the clear on every pane-carrying init, and applies each pane once per value.
  • One mirrored write path (ruling 35). persistMirrored notes the map from what the store holds after the write; writeMirroredStorage is the native-only note-first path; the hand-written notes and manual rollbacks are gone.
  • Batch storage writes stop at the first pair they cannot take (applyBatch), and a new census (mobile-mirrored-storage-write-path.test.mjs) guards the write path.
  • Envelope split into bridge-frame-fields.ts and bridge-notify-envelope.ts, with shell-screen-route.ts repointed at bridge-init-route to break the page-bundle cycle.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

… 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
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
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
…ch 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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Range 7bb9a8b9f9..7955ee6e5a — the round-6 commits on top of the last pullfrog review.

  • A page listener throw no longer escapes the bridge delivery. receive in bridge-rpc-client.ts wraps readInboundFrame and reports a new inbound-listener-threw diagnostic instead of rethrowing, so a frame the page received and failed to handle cannot reject the host's post on iOS (callAsyncJavaScript) for a frame ruling 34 tracks no retry for.
  • The held shell session moved out of bridge-rpc-client.ts into new bridge/bridge-client-shell-session.ts at the line cap, preserving the same-id in-place update (storage identity intact), different-id onReplaced, cache prime, one-shot onReady, and route-moved onRouteUpdate semantics.
  • The mirrored-write census gap is closed: mobile-mirrored-storage-write-path.test.mjs gained the last-visited-worktree-repo.ts row and a count holding writeMirroredStorage to its one caller.
  • sendInit's stale JSDoc is folded back onto the void fire-and-forget it now is, with the refused-route and refused-post cases stated plainly.

All three open threads from the prior review are addressed and resolved.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@Jinwoo-H
Jinwoo-H merged commit 3cfb070 into main Sep 21, 2026
32 checks passed
@Jinwoo-H
Jinwoo-H deleted the ota-c7-7-session-route branch September 21, 2026 20:07
Jinwoo-H added a commit that referenced this pull request Sep 21, 2026
…ached it (#22067)

C7.7 (#21977) measured the session route's page closure at 4,330 on a
merge of f07bf85 and gated green. Before it merged, #21924 turned
agent-session-wire.ts's type-only import of agent-session-record into a
value import, so src/shared/agent-session-record.ts and the two modules
it reaches, agent-session-conversation-name.ts and
surrogate-safe-text-slice.ts, entered the page bundle. Pristine main at
3cfb070 reads 4,333. Named by diffing the closure at f07bf85
against 2739246; nothing on the C7.7 side moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant