Repository navigation
feat(mobile): register the session page route (OTA phase C, C7.7) - #21977
Conversation
…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
|
Understand this PR’s impact Explore downstream dependencies and potential security impact with Blast Radius. Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe 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 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)
✅ Passed checks (3 passed)
Full details: Description checkExplanation 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.
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 3f2f41ef-2557-4f6f-a5f7-e4af07646b50
📒 Files selected for processing (40)
config/scripts/mobile-web-app-haptics-seam.test.mjsconfig/scripts/mobile-web-app-page-closure-families.test.mjsconfig/scripts/mobile-web-app-page-route-modules.mjsconfig/scripts/mobile-web-app-route-chunk-closure.mjsconfig/scripts/mobile-web-app-screencast-lane-grant.test.mjsconfig/scripts/mobile-web-app-session-dictation-capture.test.mjsconfig/scripts/mobile-web-app-session-render.test.mjsconfig/scripts/mobile-web-app-session-terminal-closure.test.mjsconfig/scripts/mobile-web-page-route-hop-coverage.test.mjsconfig/scripts/mobile-web-page-routes.mjsconfig/scripts/mobile-web-page-routes.test.mjsmobile/app/h/[hostId]/session/[worktreeId].tsxmobile/app/h/[hostId]/session/[worktreeId].web.tsxmobile/src/components/CustomKeyModal.tsxmobile/src/mobile-web-shell/MobileWebShellScreen.tsxmobile/src/mobile-web-shell/bridge-host.tsmobile/src/mobile-web-shell/bridge/page-async-storage.test.tsmobile/src/mobile-web-shell/bridge/page-async-storage.tsmobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.tsmobile/src/mobile-web-shell/page-served-back-control-a11y.test.tsmobile/src/mobile-web-shell/page-storage-keys.test.tsmobile/src/mobile-web-shell/page-storage-keys.tsmobile/src/mobile-web-shell/shell-screen-route-census.test.tsmobile/src/mobile-web-shell/use-page-host-snapshot.test.tsxmobile/src/mobile-web-shell/use-page-host-snapshot.tsmobile/src/session/MobileSessionHeader.tsxmobile/src/session/MobileSessionRouteScreen.tsxmobile/src/session/QuickCommandsSheet.tsxmobile/src/session/mobile-session-route-parity.test.tsmobile/src/session/mobile-session-route-source-family.test-support.tsmobile/src/session/mobile-structured-send-operation-journal.tsmobile/src/session/mobile-structured-send-page-storage-refusal.test.tsmobile/src/session/use-mobile-session-diff-comments.tsmobile/src/session/use-mobile-session-markdown-actions.tsmobile/src/session/use-mobile-structured-agent-session-send.test.tsxmobile/src/storage/preferences.tsmobile/src/storage/session-view-preferences.tsmobile/src/terminal/terminal-accessory-layout.tsmobile/web-entry/index.tsxmobile/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) { |
There was a problem hiding this comment.
🩺 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.
| if (enabled !== true || !hostId || route === null) { | |
| if (enabled === null) { | |
| return null | |
| } | |
| if (enabled !== true || !hostId || route === null) { |
| ) | ||
|
|
||
| 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]) | ||
|
|
There was a problem hiding this comment.
🩺 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/sessionRepository: 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 -80Repository: 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 -500Repository: 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 -500Repository: 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
There was a problem hiding this comment.
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].tsxbecomes the review switch's shape, withMobileSessionRouteScreenas the built-not-mounted native fallback and a.web.tsxsibling 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.mjsasserts every other route's grants are a subset. - Storage allowlist widened and scoped. Ten exact keys join two workspace-scoped prefixes;
pageRouteWorkspacereads host/worktree off the pathname, andpageStorageEntriesForInitbounds values/entries so one over-cap value cannot take the wholeinitframe down. - Refusals became rejections.
page-async-storagenow rejects withPageStorageRefusedError, which the durable send journal catches as "Message not sent". - Mirror writers wired. The new allowlisted keys' writers call
noteMirroredWriteso the synchronousinitreads current values. - Seam fixes found by the new render check.
BackHandleris gated onPlatform.OS, and the session/diff Back controls gainaccessibilityRole="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:57also says "ten grants"; the manifest declares 14 (the four audio verbs are included).MobileSessionActiveContent.tsx:199(comment) andterminal-viewport-refit.test.ts:160(test name) still saySessionScreen, which this PR renamed toMobileSessionRouteScreen.use-mobile-dock-resize.ts:74,77andMobileSessionActiveContent.tsx:217void-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.
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
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 28e91dd7-8b02-4496-9a06-202ab98bd8c5
📒 Files selected for processing (45)
config/scripts/mobile-web-app-page-grant-call-sites.mjsconfig/scripts/mobile-web-app-page-grant-call-sites.test.mjsconfig/scripts/mobile-web-app-session-terminal-closure.test.mjsconfig/scripts/mobile-web-page-route-hop-coverage.test.mjsconfig/scripts/mobile-web-page-routes.mjsmobile/app/h/[hostId]/session/[worktreeId].tsxmobile/src/mobile-web-shell/MobileWebShellScreen.test.tsxmobile/src/mobile-web-shell/MobileWebShellScreen.tsxmobile/src/mobile-web-shell/bridge-host-contract.tsmobile/src/mobile-web-shell/bridge-host-init.test.tsmobile/src/mobile-web-shell/bridge-host-route.tsmobile/src/mobile-web-shell/bridge-host-test-harness.tsmobile/src/mobile-web-shell/bridge-host.tsmobile/src/mobile-web-shell/bridge/bridge-caps.tsmobile/src/mobile-web-shell/bridge/bridge-client-session.tsmobile/src/mobile-web-shell/bridge/bridge-envelope.tsmobile/src/mobile-web-shell/bridge/bridge-init-frame.tsmobile/src/mobile-web-shell/bridge/bridge-init-route.tsmobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.test.tsmobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.tsmobile/src/mobile-web-shell/bridge/bridge-route-update.test.tsmobile/src/mobile-web-shell/bridge/bridge-route-update.tsmobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.tsmobile/src/mobile-web-shell/bridge/bridge-rpc-client.tsmobile/src/mobile-web-shell/bridge/page-async-storage.test.tsmobile/src/mobile-web-shell/bridge/page-async-storage.tsmobile/src/mobile-web-shell/catch-all-page-route-states.test.tsxmobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.tsmobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsxmobile/src/mobile-web-shell/page-storage-keys.test.tsmobile/src/mobile-web-shell/page-storage-keys.tsmobile/src/mobile-web-shell/shell-screen-route-census.test.tsmobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.tsmobile/src/mobile-web-shell/use-mobile-web-shell-bridge.tsmobile/src/mobile-web-shell/use-page-host-snapshot.test.tsxmobile/src/mobile-web-shell/use-page-host-snapshot.tsmobile/src/session/mobile-structured-send-page-storage-refusal.test.tsmobile/src/session/notification-pane-tab.tsmobile/src/session/session-markdown-hardware-back.test.tsmobile/src/session/use-mobile-session-markdown-actions.tsmobile/src/session/use-notification-pane-navigation.test.tsxmobile/src/session/use-notification-pane-navigation.tsmobile/src/session/use-notification-pane-navigation.web.tsmobile/web-entry/index.tsxmobile/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
There was a problem hiding this comment.
ℹ️ 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).paneKeyleaves the session switch's key, the host holds the moved route and sends a secondinitonly to a page that declaredaccepts: ['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'sstorageOversizelist 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
BackHandlergate, 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.tsis the page half of ruling 33.1 and has no test — the newuse-notification-pane-navigation.test.tsxmounts the native.ts, andbridge-route-update.test.tsstops at the client, so a regression in the seed/counter/consume logic would leave both green.
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
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
🟡 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 winRestore the mirrored journal value when persistence fails.
writeEntriesupdates the mirror beforeAsyncStorage.setItemorremoveItem. If that operation rejects, a later routeinitcan send the unpersisted journal value to the page. The native send caller reportsMessage not sentand returnsrejectedbefore 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
📒 Files selected for processing (12)
mobile/src/components/CustomKeyModal.tsxmobile/src/components/custom-key-modal-save-refusal.test.tsxmobile/src/mobile-web-shell/MobileWebShellScreen.test.tsxmobile/src/mobile-web-shell/MobileWebShellScreen.tsxmobile/src/mobile-web-shell/bridge-host-contract.tsmobile/src/mobile-web-shell/bridge-host-frames.tsmobile/src/mobile-web-shell/bridge-host-init.test.tsmobile/src/mobile-web-shell/bridge-host-route.tsmobile/src/mobile-web-shell/bridge-host-test-harness.tsmobile/src/mobile-web-shell/bridge-host.tsmobile/src/mobile-web-shell/bridge/page-async-storage.tsmobile/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.
There was a problem hiding this comment.
ℹ️ 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.
saveCustomKeysreads the mirrored value, notes the write, and on anAsyncStorage.setItemrejection restores the previous note before rethrowing, so a store refusal no longer leaves the key in the map the nextinitreads. - Route delivery now awaits the post.
sendInit/publishRoutereturnPromise<boolean>,onPageReadyhands the screen that promise, and the screen spends a one-shotpaneKeyonly once the frame actually landed — closing the "delivered beforepostresolved" gap. The send half moved into newbridge-host-frames.ts. - Two duplicated JSDoc blocks folded back in
page-async-storage.tsandbridge-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 contradictsattemptedRouteKey.current = keyat line 283.mobile/src/mobile-web-shell/bridge-host.ts:311— the folded comment reads "Carrying whether an the frame reached the page"; the leftoveran(from the old "whether aninitwent out") should go.
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
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 1531b4ce-5a34-4877-a1fc-53859967a8eb
📒 Files selected for processing (13)
mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsxmobile/src/mobile-web-shell/MobileWebShellScreen.tsxmobile/src/mobile-web-shell/bridge-host-contract.tsmobile/src/mobile-web-shell/bridge-host-init.test.tsmobile/src/mobile-web-shell/bridge-host-route.tsmobile/src/mobile-web-shell/bridge-host-test-harness.tsmobile/src/mobile-web-shell/bridge-host.tsmobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.tsmobile/src/mobile-web-shell/bridge/bridge-route-update.tsmobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.tsmobile/src/mobile-web-shell/use-mobile-web-shell-bridge.tsmobile/src/session/mobile-structured-send-operation-journal.test.tsmobile/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.
| if (update.kind === 'hold' || inFlight) { | ||
| return | ||
| } | ||
| inFlight = true | ||
| void args.sendInit().finally(() => { | ||
| inFlight = false | ||
| }) |
There was a problem hiding this comment.
🎯 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-shellRepository: 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.tsRepository: 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.tsRepository: 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.
There was a problem hiding this comment.
ℹ️ 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.
createBridgeHostRoutetracksdeliveredseparately from the held route, measures movement againstdelivered(so a refused frame and a repeat tap still read as moved), and reports delivery throughonRouteDeliveredonly oncesendInit's post resolves;publish/retryreturn nothing andonPageReadyreverts to() => void. - A stranded route gets retried.
retryPendingRoutefires when the view handle is regained, andMobileWebShellScreen's effect no longer tracks the attempt itself. - The send-journal mirror is rolled back on a refused persist.
writeEntriesrestores the prior mirrored value whenAsyncStoragerejects, soinitcannot 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 aboveonPageReadyand still says "deliveredsettles true once theinit… reached the page", a parameter this delta removed (onPageReady: () => void). The:176-180block already documents the field; the second is a leftover from the round-2 signature.
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
There was a problem hiding this comment.
ℹ️ 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).
createBridgeHostRouteholds only the route now;attempt/landed/inFlight/retryPendingRoute/onRouteDeliveredand the session switch'sclearPaneKeyare gone, replaced by a page→shellroute-param-clearnotify declared ininit.accepts. - The page applies a pane and erases the request it came on.
use-notification-pane-navigation.web.tsseeds from the openinginit, re-asks the clear on every pane-carryinginit, and applies each pane once per value. - One mirrored write path (ruling 35).
persistMirrorednotes the map from what the store holds after the write;writeMirroredStorageis 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.tsandbridge-notify-envelope.ts, withshell-screen-route.tsrepointed atbridge-init-routeto break the page-bundle cycle.
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
There was a problem hiding this comment.
✅ 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.
receiveinbridge-rpc-client.tswrapsreadInboundFrameand reports a newinbound-listener-threwdiagnostic 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.tsinto newbridge/bridge-client-shell-session.tsat the line cap, preserving the same-id in-place update (storage identity intact), different-idonReplaced, cacheprime, one-shotonReady, and route-movedonRouteUpdatesemantics. - The mirrored-write census gap is closed:
mobile-mirrored-storage-write-path.test.mjsgained thelast-visited-worktree-repo.tsrow and a count holdingwriteMirroredStorageto 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.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…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

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
MobileSessionRouteScreenas its fallback (commit 1) plus a.web.tsxsibling; 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). NoBRIDGE_PROTOCOL_VERSIONbump, no new frame kind, nothing undermobile/rpc-foundation/; the page'sreadygained an optionalacceptsarray andinitan optionalstorageOversizelist, 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 grantsnative.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
paneKeyonto 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-sendsinitfor 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 ononPageReady,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→shellnotify, declared by the shell ininit.accepts(the mirror ofready.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. PuttingpaneKeyback in the key reds exactly the two pane cases. Round 6 (pullfrog on7bb9a8b9f9). 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'sreadycarries the route; on iOScallAsyncJavaScripthad 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)>, sopageStorageKeysForRoute/isPageStorageKeyForRouteread host and workspace off the route pathname.orca:hostDockWidthandorca:hostSidebarWidthare included: preferences the native screen reads, which the page may not lose. An unsent-send journal over theinitframe's value bound (measured: 48 unsettled sends at 342 chars each exceed it, 47 do not) is dropped frominitand named rather than refusing the whole frame, since the schema refines on the bound; native storage keeps the journal. An oversize value rejects withPageStorageRefusedError, 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 awaitssetItemwith no catch. A key dropped frominitfor size is no longer writable by that page session (ruling 33.6): the shell names it instorageOversizeand 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 nextinitis 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 aninitbuilt 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.oversizeKeysstays 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).
X → session; none outbound, asserted as grant coverage)Two defects found by the render check.
use-mobile-session-markdown-actions.tsregisteredBackHandlerunguarded on web and re-registered on every dirty-draft change; gated onPlatform.OSlike 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.tsrunsvoid loadDiffComments()with no catch, so a rejectedworktree.showis 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.tsxandQuickCommandsSheet.tsxgainedaccessibilityRole="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.mjsfornavigate,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) reachopenExternalLinkthrough the shared layout's protocol wall without declaringexternalLink; pre-existing, recorded asKNOWN_UNDECLARED, its own PR. Two modules came out at their own boundaries rather than a line-cap bump,bridge-init-route.tsandbridge-host-route.ts.mobile-web-app-route-chunk-closure.mjslooked routes up by exact path and read "no output" for the first route with a.web.tsxsibling. The render check does not paint a terminal and says so;mobile-web-app-terminal-render.test.mjsis the terminal's browser proof.Commits
06dd168620e5692406a757370a36c6185914430dc41dcdcabfc5b85dea205fe8cfe1b39e80799719beda1cc384origin/main(a91ca8b19e): one conflict in the session closure pin, re-measured on the merge52628392bea9f3ac771d40617a6187origin/main(5d51bcaf6e, C8): flag-census conflict, both registrations kept;firstParamrepointedf2a8804866,04eee129efinit(ruling 33.1)d23e9a8acbBackHandlergate (ruling 33.2)65e34b37c9,b1fe2937819a857517945a22ec7827.web.tsoverrides row9423b39f24initcould not carry (ruling 33.6)1294656b6dorigin/main(4aaa6c7fb4), no conflicts8b4c559e90initwent out (round 2)768aa8ddb9b19983779f81816833a2c03d5e6b153e43a2dbb7initfor size, so an older page cannot clobber the journal (bots; ruling 33.6 amended)e2565405cc47eb9f1fe4553ba041edbridge-host-frames.tssplit out at the line cap (bots)b27bf7959ae89ab25d46fba8cb3f3d8b81dd37b2init.accepts(ruling 34)ec2b8d5fa8initon a route change and track nothing else; pending/held/delivered/in-flight,retryPendingRoute, the delivery promise andonRouteDelivereddeleted (ruling 34)9d8920470ainitand asks for the param to be erased, naming what it applied (ruling 34)ba5ecae8f6bridge-notify-envelope.tsandbridge-frame-fields.tsout ofbridge-envelope.tsat the cap, an esbuild cycle that read as a TypeError in twelve render filesbe582e0c542af076429083d63cee1da72a5b80f07bb9a8b9f9origin/main(f07bf8544c), no conflicts; closure pin unchanged at 4,330 / 9889508e25e3ewriteMirroredStoragecallers (round 6)6e4dbaf03csendInitdescribes the fire-and-forget it is (round 6)517037aac8receivecatches 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)7955ee6e5abridge-rpc-client.tsat the line cap (277 counted after)Verification
Lead gate on
beda1cc384againsta91ca8b19e: all lint gates 0;config/scripts246 files / 2,230 passed; mobile 785 / 8,770 passed. Lead gate on1294656b6dagainst4aaa6c7fb4: all lint gates 0;config/scripts247 files / 2,254 passed; mobile 798 / 8,866 passed. Lead gate one2565405ccagainst4aaa6c7fb4: all lint gates 0;config/scripts247 / 2,254; mobile 800 / 8,881. Lead gate onb27bf7959aagainst4aaa6c7fb4: all lint gates 0;config/scripts247 / 2,254; mobile 800 / 8,883. Lead gate onfba8cb3f3dagainst4aaa6c7fb4: tsc, ratchet, oxlint, format, anti-slop, both changed-code gates, react-doctor all 0;config/scripts247 files / 2,254 passed; mobile 800 / 8,888 passed; no machine paths.Lead gate on
7bb9a8b9f9againstf07bf8544c: tsc, ratchet, oxlint, format, anti-slop, both changed-code gates, react-doctor all 0;config/scripts248 files / 2,260 passed; mobile 801 / 8,897 passed; no machine paths.Lead gate on
7955ee6e5aagainstf07bf8544c: all lint gates 0;config/scripts248 files / 2,262 passed; mobile 801 / 8,899 passed; no machine paths.