Commit Graph
17 Commits
Author SHA1 Message Date
Jinwoo Hong bd5177801b feat(mobile): put the page's pickers, paste and editor fallbacks on the media verbs (OTA phase C, C7.6) (#21795)
* feat(mobile): put media picking behind a platform seam (OTA phase C, C7.6)

The session screen picks images three ways — the photo library, Files, and the
pasteboard — and all three are native modules a page cannot import: the codegen
lookup `expo-image-picker` and `expo-document-picker` run at import throws in a
browser, and the route manifest imports every route, so one of them in a page
closure is the whole bundle down rather than one picker.

`src/platform/media-picker.ts` is the phone's, delegating to the same three
calls the screen already made. `.web.ts` is the page's: `native.media.pick`,
then `read` in order to `eof`, then `release` for every handle it was handed,
including the ones its caller never took — the shell holds eight staged files
at a time and an abandoned pick otherwise waits out the five-minute TTL. A
refusal rejects with the shell's code on it and is never folded into the empty
answer that means the user cancelled.

The bytes are concatenated decoded and encoded once, because the wire promises
`eof` and nothing about the length: a shell answering a range shorter than the
one asked for ends a chunk on a partial base64 group, and a reader joining the
strings would fold that padding into the middle of the file.

The census walks the session route module's own closure — the route is not
registered until C7.7 — and names any module that reaches a picker or
`Clipboard.getImageAsync` directly. Today that is the two modules C7.6's next
commit moves, listed by name so the list goes empty rather than the rule going
quiet.

Inert: nothing calls the seam yet.

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

* feat(mobile): put the session's paste and attach on the media seam (OTA phase C, C7.6)

The terminal paste read the pasteboard through `expo-clipboard` directly and
the two attach paths called `pickMobileImage`/`pickMobileImages`, so the page's
closure carried `expo-image-picker` and `expo-document-picker` — native modules
whose import throws in a browser.

All three now go through the seam. Text is `native.clipboard.read` on the page,
which the clipboard seam gains a reader for: `expo-clipboard` resolves to
`navigator.clipboard` there, which needs a secure context the iOS shell's
custom scheme is not. An image is `pick { source: 'clipboard' }` rather than an
inline value, because a clipboard image is 24 MiB of base64 against an 8 MiB
reply ceiling.

The census over the session closure is empty now and asserts the seam is in it,
so a rule that found nothing is one that had something to find: with the three
call sites restored it names all three.

`mobile-image-source-picker.ts` stays the phone's implementation, reached only
through the seam's native sibling, and resolves out of the web closure entirely.

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

* feat(mobile): resize a clipboard image on the page with a canvas (OTA phase C, C7.6)

The paste hook carried the raster shrink inline over `expo-image-manipulator`
and two `expo-file-system` writes. Both are native: the manipulator has no
browser build, and the temp file exists only to work around an iOS loader that
cannot decode a large base64 data URI, which a browser does not need.

Split into `mobile-clipboard-image-resize.ts`, unchanged, and a `.web.ts` that
decodes one `<img>` from the data URL the shell's `img-src 'self' data:`
already admits, draws it into a canvas at the target size and reads the PNG
back out of `toDataURL`. It reports the canvas's own size rather than the size
asked for, because a browser clamps a canvas past its area limit and the
downscale loop above would otherwise retry a raster that never shrank; and it
awaits `decode()` rather than `onload`, which never fires for a source the
browser cannot read and would leave the paste waiting on a promise nothing
settles.

Measured in Chromium under the shipped header, on a noise PNG because that is
what PNG compresses least: 1400x1000 encodes to 5,476,032 base64 characters and
converges in one pass to 368x263 and 397,220, which is 75.8% of the upload
path's 512 KiB chunk. Zero policy violations and zero page errors. Red under
three mutations: the source returned unchanged, a reported size the canvas did
not draw, and `onload` in place of `decode()`.

`computeMobileClipboardImageDownscale` moves to a leaf for the reason the
upload-chunk constant has one: the check wants the arithmetic and not the
upload path's RPC operations behind it.

The page closure now carries none of `expo-image-picker`,
`expo-document-picker`, `expo-image-manipulator` or `expo-file-system`, pinned
beside the seam census.

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

* feat(mobile): give the two WebView editors their plain web fallbacks (OTA phase C, C7.6)

`MobileRichMarkdownEditor` and `MobileHtmlPreview` are the session closure's
other two `react-native-webview` consumers. On the web that package renders the
line "React Native WebView does not support this platform" where the surface
was, so nothing it was mounted for works and the closure pays for a module that
cannot do its job.

Ruling 8: each gets the plain state it already degrades to, and no second
renderer. The editor renders the Markdown source in one field on the text-input
seam, so the screen around it keeps the text, every edit through `onChange`,
and Save, Discard, Copy and Refresh; the degradation is the formatting toolbar,
whose fifteen commands are the rich document's. The preview renders its own
Source tab; the degradation is the rendered artifact, and the toggle goes with
it, because a control that can only be in one position is a control that lies.

Neither is smaller than a DOM renderer, which is why neither is one here. The
editor's toolbar would need a `contenteditable` implementation with its own
escaping, and the preview has no nested frame to sandbox agent-produced HTML in
at all — the shell's policy carries `frame-src 'none'` and `child-src 'none'`.

`dismissKeyboard` blurs the field rather than calling `Keyboard.dismiss`, which
is a stub on React Native Web; `onKeyboardInsetChange` is never called, because
it exists to correct for a WebView's covered area and on the page
`keyboard-occlusion.web.ts` is the only measurement there is.

The closure census names the one consumer left, `TerminalWebView.tsx`, which is
C7.5's: with both siblings removed it names all three.

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

* fix(mobile): read a destructured clipboard alias in the media census (OTA phase C, C7.6)

The census recognised `Clipboard.getImageAsync` as a property access and
nothing else, so `const { getImageAsync } = Clipboard` reached the same
function without ever writing one and the closure was approved. On the page
that call is `navigator.clipboard`, which needs a secure context the iOS
shell's custom scheme is not, so the approval was for a path that dies at the
browser clipboard API.

Aliases are now resolved to a fixpoint — `const pasteboard = Clipboard` makes
`pasteboard` the module too, and the chain has no length limit — and a
destructuring off any of them is reported at its declaration, which is the line
to delete. The destructured name is read the way the import clause's is, off
`propertyName` when the element renames it, so `{ getImageAsync: readImage }`
is the same offence spelled differently. A binding element's `name` can be a
nested pattern and a `propertyName` can be computed, so the text is taken only
off a node that has one.

Red-first with each shape planted in the scratch tree before the rule moved:
the plain destructuring, the renamed one and the re-destructured chain were all
missed. Dropping the fixpoint afterwards loses the chain; reading the local
name instead of the property loses the rename. `{ getStringAsync } = Clipboard`
stays unreported, because text off the pasteboard is the clipboard seam's and
not this rule's.

The session closure is still empty under the widened rule.

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

* fix(mobile): release every item a clipboard pick answered (OTA phase C, C7.6)

`readClipboardImage` destructured the first staged item and released only that
one, while `pickImage` already guards the same shape through `readPicked`.
`multiple: false` is what the page asks for and not what a shell promises, so a
caller taking the first of several would hold the rest against the
eight-handle cap until the five-minute TTL. Today's shell stages at most one on
the clipboard arm, so this is the seam's own docstring made true rather than a
leak in the field.

Red-first with two staged clipboard items: releasing only the one read leaves
`media-2` held, and the second is now returned without ever being read, which
is what the single-image pick does.

The refusal case is one path over both codes a pick can answer with: the
registry's `native_media_handle_cap`, raised before a picker runs, and ruling
6c's `native_media_too_large`, raised once a picked item has been weighed. A
code outside the seam's vocabulary floors to `native_verb_failed` rather than
crossing verbatim, which is what makes naming the exact code load-bearing.

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

* test(mobile): read the upload chunk from its module in the resize check (OTA phase C, C7.6)

The canvas resize check restated `512 * 1024` as the budget it holds a run to.
A check carrying its own copy of a product constant is one that goes on passing
after the upload path's chunk has moved, which is the reason the harness reads
the CSP, the protocol version and the window caps out of their own sources.

`readClipboardImageUploadChunkBase64Chars` joins them, evaluating the product
the way the window caps reader does. Proved live by moving the constant: at
64 MiB the run reds on the fixture no longer being over the budget, and it is
back to 512 KiB here.

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

* fix(mobile): read element access in the media census and correct two claims (OTA phase C, C7.6)

Round 2, four lows.

The census read `Clipboard.getImageAsync` and not `Clipboard['getImageAsync']`,
which is the same call, the spelling a bundler produces, and the one a reader
reaches for to get around a rule about dots. Element access with a string
literal is now read the same way; a computed key is not, because its value is
not in the source and guessing would report a line nobody can act on. The
closure test cannot back this up — `expo-clipboard` legitimately sits in the
session closure — so the scratch fixture is the whole of the evidence, and it
reds with the arm removed.

The fixture also could not tell the alias fixpoint from one source-order pass:
every planted chain happened to be declared in the order a single walk learns
it. `reverse-order-alias.ts` is declared back to front, and is valid at run
time because the destructure sits inside a function the module body finishes
before anything calls. Bounding the loop to one pass now reds it.

The canvas resize justified reading its size back off the element by a browser
clamping past its area limit. That is not what browsers do: the width attribute
reflects whatever it was assigned, so the returned size is always the target.
The real reason is narrower and is now what the comment and the override entry
say — the dimensions and the bytes come from one element, so a caller's
bookkeeping cannot describe a raster that was not encoded.

The override entry also carried a stray apostrophe in `img-src 'self' data:`.

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

* fix(mobile): say what the clipboard contract shipped as, and read a backticked key (OTA phase C, C7.6)

Round 3, three lows.

The merge took main's `clipboard.ts` byte for byte, so its reader docstring
still described the state C7.2 shipped: one verb on the web, and a page whose
lack of an image verb degraded into the old path. The design that shipped is
the other one — this seam owns the pasteboard on both platforms and the page's
`readImage` runs `native.media.pick { source: 'clipboard' }` with the chunked
read behind it. The prose now says that, and says that null still means an
empty pasteboard while every other outcome rejects.

The same merge left `clipboard` twice in the paste hook's dependency list, one
from each side. Deduped.

The census read a quoted element-access key and not a backticked one, so
``Clipboard[`getImageAsync`]`` escaped a rule that catches both other
spellings. A template with no substitution is a string literal with a different
quote, and reading only one of the two leaves the other as the way around.

The computed-key plant could not see the literal-kind check at all: its
variable was named `key`, so reading the identifier's text found nothing
either way. It is now named after the method and holds a different one, which
makes dropping the kind check a false positive on a call that reads text.

Red-first: the backticked access planted before the rule moved is missed;
ignoring template keys afterwards misses it again; accepting any key node
reports the computed plant.

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

* fix(mobile): hold canPickMedia to all three verbs and seed the census from import() (OTA phase C, C7.6)

Two bot findings.

`canPickMedia` answered true on `native.media.pick` and `native.media.read`
alone, but every image read releases what it picked. On a route without
`native.media.release` the release rejects, the cleanup swallows it by design,
and the staged file stays live to the five-minute TTL: eight pastes and the
next pick is refused at the handle cap, with nothing on screen to say why. A
route missing one verb has no working image path, so `contents()` now says so
up front rather than after four of them. Red-first: a route granted pick and
read but not release answered `image: true`.

The census seeded its aliases from static import and export declarations only,
so `const Clipboard = await import('expo-clipboard')` produced no offender —
while the bundler resolves a literal dynamic import into the closure exactly as
a static one. A dynamic import is now read wherever it appears: `await` and
parentheses unwrapped, the assigned identifier seeded as an alias, a
destructuring off one reported at its declaration, and a picker module reported
at the call, since reaching one at all is the offence. A specifier that is not
a literal is left alone, for the reason a computed key is.

Red-first with all three forms planted and the seeding removed: the namespace
alias, the destructuring and the picker import are each missed.

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

* fix(mobile): seed the media census from a backticked import() too (OTA phase C, C7.6 bots)

CodeRabbit: `import(`expo-image-picker`)` is as static to the bundler as the
quoted form, but the census read only a string literal specifier, so a
backticked one joined the closure unseen. A no-substitution template literal
now seeds it the same way; the planted fixture is reported at its line and
was unreported before the arm.

pullfrog: the clipboard seam's docstring counted the web read as two verbs
where its web sibling counts one for text and three for an image. It now
counts the same way in both files.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 11:24:53 -04:00
Jinwoo Hong d5dc7b9cf8 feat(mobile): budget the terminal snapshot on serialized bytes and hold live output instead of ending the stream (OTA phase C, C7.3) (#21785)
* fix(mobile): budget the mobile terminal snapshot on the bytes it serializes to (OTA phase C, C7.3, ruling 1)

The desktop trims a mobile snapshot to 512 KiB of raw terminal text. A client
reading it through the page bridge measures the serialized event against a
640 KiB frame cap, and an ANSI snapshot is mostly ESC bytes, each of which JSON
spends six on. Measured here on a colour-dense 80-column screen: the raw budget
hands back 465,766 bytes that serialize to 669,268 — 102.1% of the cap — so
`deliver` answers `cancel(id, 'overflow')` and the terminal is dead before its
first live byte, with no recovery that does not reproduce it.

`terminal.subscribe` gains an optional `snapshotByteBudget`. A subscriber that
sends one is trimmed against the JSON its payload will really cost: the escaped
text, plus the metadata it cannot bound from its own side — a path, the OSC-link
list, the pending escape tail. A subscriber that sends none, which is every
socket client and every older page, keeps the raw byte rule exactly.

No negotiation, and none is needed: the field is additive and optional, so an
older desktop ignores it and trims as it always did. The page then still has a
snapshot over its cap, the shell still ends the stream with `overflow` (C0.3
stands), and the terminal renders its stream-error state rather than a blank
pane. The page derives the number from the cap less the event envelope rather
than writing it down, so a cap that moves takes the budget with it.

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

* feat(mobile): hold and coalesce terminal output instead of ending the stream on the window (OTA phase C, C7.3, ruling 2)

The shell's backpressure window ends a stream when the page falls 4 MiB behind.
That is right for a stream whose reader can survive a gap and wrong for a
terminal, whose reader cannot see the hole a dropped chunk leaves — and the
window does not wait for a page to go wrong. Measured by the design: the host
produces 70.3 MiB/s of JSON and real xterm applies 2.2 MiB/s, so an ordinary
`cat` crosses the window in 62 ms. Replayed here through the real ledger against
a page draining at that rate, a 5 MB transcript ends the stream after 85 of 107
chunks plain and after 40 of 107 under `grep --color`.

Keyed by method on the shell, since the page cannot pick its own window,
`terminal.subscribe` now holds what it cannot send, merges consecutive output in
escaped bytes under the frame cap, and delivers as the page acks. Nothing is
dropped: merging concatenates, and the only exit that loses bytes is ending the
stream, which the page is told about. Both transcripts now arrive whole and in
order, in 104 and 81 frames, with the largest frame at 622,551 bytes against the
655,360-byte cap.

It ends only on the two things that are not slowness: a page that has acked
nothing for 20 s, an order of magnitude above the 1.9 s a full window takes to
drain, and a backlog past 32 MiB, which at that drain is about 15 s of catching
up. Both reach the page as `overflow`, because the shell is the installed app
and its page comes from the desktop, so a reason the page's reader has never
heard of is a frame it drops rather than an end it acts on. Which one fired, the
coalesced-frame count and the peak pending bytes go to the diagnostic log, which
is the device proof's only oracle for any of this.

Every other stream keeps the byte window exactly, and an event over the frame cap
still ends any stream, terminal or not (C0.3). The landed window cases now name a
stream the window still governs, so the two rules are never read off each other.

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

* fix(mobile): narrow the event arm the backlog replay reads

A binary event carries no `payload`, so the tests-typecheck ratchet refused the
reach into it.

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

* refactor(mobile): narrow the snapshot serializer to the buffer source it reads

The changed-code casting gate refused the test's stub runtime, and it was right
to: a service-wide type for a function that calls one method is what made the
stub need an assertion. The parameter now says what it needs, and the fixture
path is no longer one a machine-path grep reads as a leaked local checkout.

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

* fix(mobile): measure the snapshot budget by building the payload, not by summing fields (OTA phase C, C7.3, ruling 14)

Round one summed the escaped text and four metadata fields. The payload a
bridged client assembles carries nine more — `kind`, `cols`, `rows`,
`requestId`, `displayMode`, `reason`, `seq` and both truncation flags — plus the
`type` and `streamId` it adds, the `serialized` key and the object's own braces.
So a snapshot this host accepted at exactly the budget, with
`truncatedByByteBudget` false because nothing had trimmed it, published over the
cap and the stream ended with `overflow` before a byte was painted.

Measured here on a screen sized to land exactly on round one's budget: the
published payload is 655,446 bytes against a 655,273-byte budget, 173 over, and
the frame it makes is over the 640 KiB cap by the same amount.

The metadata is now built by one function that `sendSnapshotFrames` and the
budget both call, and the budget stringifies the payload that function produces.
Nothing is summed and nothing is estimated, so a field added to the frame is paid
for by the budget the moment it is sent. Where a value is not yet known — the
truncation flags, and `seq` or `requestId` at a site that has not fixed them —
it is measured at the widest `JSON.stringify` can write it, which is a bound
rather than a guess, and forcing `seq` to a number also opens the three fields it
gates so those are counted too.

The budget therefore travels with the publication fields, because the payload
cannot be built without them.

On the page, the event envelope is now derived in one place in the protocol
module and read by both the snapshot budget and the shell's own merge budget, so
the two cannot drift; the page pins the number it sends and the host's cases name
that pin, since the two programs cannot import from each other.

The case that re-implemented the host's measure is gone: it could not have seen
this, because it was the same arithmetic twice.

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

* fix(mobile): arm a held terminal's silence clock only while something is pending (OTA phase C, C7.3)

The invariant is "armed implies waiting on the page", and round one broke it in
the one direction that kills: an ack re-armed the clock and the drain that
followed emptied the queue without clearing it. A terminal that had delivered
every byte and gone quiet — which is what a terminal does between commands —
would die on `overflow` twenty seconds later.

The clock is now synchronised after every change to the queue, so it is armed
exactly while something is held. A rule that only ever arms is a rule that only
ever ends more streams.

Red-first: with round one's arming, an idle stream whose queue has drained still
reports its clock armed, and firing it ends a healthy terminal.

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

* test(mobile): pin the held-stream cases the rulings name (OTA phase C, C7.3)

Six cases nothing covered. Two subscriptions on one shell keep separate backlogs,
so a busy terminal cannot end a quiet one. A stream the page unsubscribed mid-
backlog posts nothing after, and neither does one that has already ended, however
much was still held. A payload that is not output breaks a merge run and keeps
its place, because a resize is state the reader applies in order. And the budget
boundary is checked on the side that enforces it: a payload at exactly the number
the page asks the desktop for is delivered inside the cap, and one the cap cannot
hold ends the stream under C0.3.

The replay no longer acks unconditionally in its catch-up loop. That was the page
behaving better than a page can — it acks on reading frames — and it is what hid
the silence clock left armed over an empty queue. The held-stream cases close the
window on its frame count rather than on four megabytes of string work.

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

* refactor(mobile): give the event-envelope derivation its own module (OTA phase C, C7.3)

`bridge-envelope.ts` is at its line cap and is the protocol's schemas; what a
frame costs around its payload is a derivation over them, and two budgets read
it — the snapshot the page asks the desktop for, and the output the shell merges.
One module, so they cannot drift and neither file is pushed over its limit.

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

* test: narrow the budget fixtures instead of asserting them

The changed-code casting gate refused six `as NonNullable<...>` in the new
budget cases, and it was right to: a fixture that serialized nothing is a broken
case rather than a null to assert away.

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

* refactor: give the snapshot payload shape its own module (OTA phase C, C7.3)

`terminal-snapshot-publication.ts` crossed the root config's 300-line cap, which
mobile's own lint does not apply and CI does. The frame's shape and what it costs
a client reading it as one payload is a description the budget and the sender
both need, so it is the part that leaves.

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

* fix: empty a snapshot the budget cannot fit instead of posting it over (OTA phase C, C7.3, ruling 15)

Both trimming loops published the zero-row candidate whatever it measured, and
zero scrollback is not a small screen: a wide colour-dense viewport still carries
its 24 live rows. A capped subscriber could get one frame over its cap, end the
stream on `overflow` and paint nothing — worse than a blank terminal, because a
blank one repaints on the next byte of output and a stream that never opened does
not reopen.

Ruling 15: a budgeted subscriber gets that frame with its text emptied and
`truncatedByByteBudget` true, never over and never refused. The raw rule keeps its
fallback, so an older page and every socket client are served exactly what they
were before. Below the metadata the frame must carry there is nothing left to give
up, and that boundary is pinned rather than claimed away.

The renderer loop is the same walk reached by a different caller and had no test
at all; its runtime parameter is narrowed to the two methods it reads so a case
can stub it without a cast.

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

* fix(mobile): report what a held terminal stream did instead of calling it an outlived view

The backlog report had no branch in the reporter, so it fell through to the "a
view outlived its host" warn and every field it exists to carry was discarded.
The key made it worse: keyed by kind alone, one backlog per host was ever logged,
and a shell holds one stream per open terminal.

That report is the only oracle the coalescing rule has. Nothing crosses to the
page saying how much was held or how many frames its bytes arrived inside, and
both ways a held stream dies reach the page as `overflow`, because a reason its
reader has never heard of is a frame it drops. In production the two rules were
indistinguishable. They are now a line each, per stream.

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

* test: give the renderer fixture the source its serializer returns

`serializeRendererTerminalBuffer` answers `renderer`, and vitest does not
typecheck, so the stub's `headless` passed every run and failed the node
typecheck instead.

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

* fix: budget the frame the publication actually sends (OTA phase C, C7.3)

The budget and the publication were written out twice, five lines apart, and had
drifted at every site: a budget for `{kind:'scrollback'}` approved a frame sent as
`kind:'resized'` with a `reason` beside it, and the live module budgeted
`pending-output-overflow` while sending `renderer-mount-ready`. It held only
because the padded `requestId` and `seq` are absent from those frames and more
than covered the difference. Each site now builds one object and hands it to
both.

`displayMode` cannot travel that way and was a third under-measure nobody had
named: the subscribe flow re-reads it from the runtime after the snapshot is
serialized and before the frame is sent, so no caller can tell the budget which
mode the publication will carry. It joins `seq`, `requestId` and the truncation
flags as a field taken at its widest. The mode list resolves the constant to
`never` if the runtime gains a mode it does not carry, so a new one is weighed
here rather than found on a phone.

Red-first needed a second attempt: the first fixture had trimming slack, so three
extra bytes fit and the probe could not see the defect it was written for. The
case now budgets a fixed screen at exactly its `auto` measure, where the margin
is the whole of the test.

One figure for the overshoot everywhere, with its basis: 169 bytes over the
655,360-byte cap on a frame carrying an 8-character request id, 247 with a
24-character one. Three places said 169 and one said 173.

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

* refactor(mobile): delete two backlog guards no input can reach

Both survived mutation because neither is reachable, and neither became
reachable when I tried to write a case for it.

`next` narrowed the merge ceiling to one frame, but its only caller,
`drainTerminalBacklog`, has already narrowed it: the parameter is what one
payload may occupy, not what the window holds, so the second narrowing could
never change the answer. The parameter now says so and the class no longer needs
the frame size at all. The bound still lives in the caller and is still covered:
removing it there reds a delivery case.

The merge run also compared stream ids, but a backlog belongs to one subscription
and every `data` payload on it carries that subscription's single stream id, so
the comparison could not fail. The run still stops at anything that is not
output, which is reachable and pinned.

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

* docs(mobile): record the invariant the deleted stream-id guard rested on

The merge run compares no stream ids because it cannot need to: a backlog belongs
to one subscription and every `data` payload reaching it carries that
subscription's single stream id. Written down where the run is, because the thing
that would break it is a change made somewhere else — multiplexing two streams
onto one record would merge their output into one payload under the first id.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 10:19:41 -04:00
Jinwoo Hong e736a9f29b feat(mobile): take the session screen's inputs, links, clipboard and routers through the platform seams (OTA phase C, C7.2) (#21790)
* feat(mobile): put the session screen's nine text inputs on the web font seam (OTA phase C, C7.2)

The landed text-input census, run over `app/h/[hostId]/session/[worktreeId].tsx`,
reports nine sizes that do not come from `TEXT_INPUT_FONT_SIZE`. Six declare the
app's body size and move in place, which is the same number natively. Three do
not — a 22px key-capture field and the chat's two 15px fields — so each gets a
`.web.ts` sibling of the address bar's shape, with a shared base so the two
halves can differ in nothing but the size.

The capture field is the one the move shrinks rather than raises: 22 already
clears the focus-zoom floor, and the census reads the seam as a binding rather
than as a number, so there is no expression that keeps 22 and still says where
the size came from.

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

* fix(mobile): drop the theme import the composer's style split left behind

`oxlint` over the whole tree, which CI runs, reads it as an error.

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

* feat(mobile): open the session screen's three external URLs through the platform seam (OTA phase C, C7.2)

The landed external-link census, run over the session route's closure, reports
three modules reaching react-native's `Linking`: a terminal link tap whose open
mode is the phone's browser, and the two WebView-backed readers, each of which
sends a tapped link to the system browser rather than navigating the artifact
away.

Inside the shell `Linking.openURL` calls `window.open`, which both shells refuse
and which resolves either way, so all three reported success into a tap that did
nothing. The seam also stops swallowing the failure: each site caught and
discarded, and `openExternalLink` names it.

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

* feat(mobile): take the session screen's seven clipboard sites through the platform seam (OTA phase C, C7.2)

`expo-clipboard` resolves to `navigator.clipboard` on the web, which needs a
secure context — iOS serves the page from a custom scheme and Android from
`https`, so that path works on one platform and silently not on the other. The
landed census now reports the module out of the route's closure entirely.

The seam grows its reader half, on the landed `native.clipboard.read` verb: text,
a PNG, and a presence probe. Two degradations are recorded rather than implied.
No shell serves an image, so the page answers null and the terminal's paste takes
the branch an empty clipboard already took; and the shell serves no presence verb,
so `contents` answers what this side knows rather than reading to find out, which
would raise iOS's paste-consent prompt on every foreground.

The copy-path sheet gains the failure toast its two neighbours already had: it
showed "Path copied" before the write, and the seam rejects rather than returning
false.

The route parity pin moves with it: five clipboard hooks join the expanded route
and one runtime string joins the sheet.

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

* feat(mobile): take the session domain's three routers through the handoff seam (OTA phase C, C7.2)

Inside the page a screen is one document standing in for one screen, and
`useRouteHandoff` is the only thing that knows which targets the page keeps and
which it hands back to the app. The three holders here are the workspace-missing
bounce, the file-tap preview push, and the pane-tap param consume.

The domain's census is narrower than the two landed ones because it has to be:
eight of its hooks take `useFocusEffect` and two take `useLocalSearchParams`,
neither of which can navigate, so the rule is a closed list of names rather than
a ban on any value import — which also catches expo-router's module-singleton
`router`, a spelling a `useRouter` rule would have read as clean.

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

* feat(mobile): list the two style siblings the session screen's inputs added (OTA phase C, C7.2)

The overrides census fails on an unlisted `.web.*`. One raises the chat's two
15px fields past the focus-zoom floor; the other lowers a 22px capture field onto
the seam, and its entry says why a reduction is the right answer there.

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

* feat(mobile): let the text-input census read a literal already clear of the floor (OTA phase C, C7.2, ruling 12)

The floor is the rule and the seam is the mechanism. A binding rule alone made
the custom-key capture field an offender at 22, where nothing can zoom, and the
only way to satisfy it was to lower a one-character field to 16 — the tail
wagging the dog.

The seam's web half now exports the floor it already computed `Math.max` against,
and the census reads that number out of that file rather than carrying a second
copy of 16. The rule becomes "the seam's binding, or a literal at or above the
floor", with no per-site exemption: a literal under the floor is still reported,
which is the case the seam exists for. A tree whose seam declares no floor is
refused rather than judged against a number the census invented.

So the capture field goes back to 22 on both platforms and its split, its
override entry and its parity test go with it. The chat's two fields stay split,
because 15 is under the floor however it is spelled.

Red-first: with the rule removed, a planted literal 16 and a literal 22 are both
reported and the refusal case does not throw; a literal 15 is reported either
way. All three route closures that run this census — session, source-control,
review — report 0 offenders and 0 unresolved.

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

* fix(mobile): answer clipboard reads on the read grant and catch refused writes

`contents()` reported no text on a route that granted `native.clipboard.read`
without the write, because it read `verbs.granted`, which is write AND read. The
verbs hook now exposes the two grants separately and the web seam answers on the
read one; `granted` keeps its meaning for the callers that need both.

The Markdown copy action was the one write of eight in the session domain with
nowhere for a rejection to go: the seam rejects when the pasteboard refused the
text, the callback had no failure branch, and its caller drops the promise, so a
refused write raised an unhandled rejection and still left "Copied" on screen. It
now takes the error haptic and the "Couldn't copy" toast the other copy paths
show. A census over `src/session` fails if any `writeText` call site lacks a
failure branch, so the ninth site cannot arrive without one.

The route parity pin moves with it: one callback body, one runtime string. Its
refresh note claimed six clipboard hook sites for a delta of five; the walk from
`SessionScreen` reaches five, and the terminal's paste is not among them.

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

* fix(mobile): probe both clipboard kinds at once and judge sizes against the floor

Moving the two clipboard probes into an object literal serialised them: the
migrated `contents()` awaited `hasStringAsync` before `hasImageAsync` was called,
where both callers had used `Promise.all`. That path runs on mount, on every
AppState foreground and on every select-mode toggle. Restored, with an ordering
probe that deadlocks unless both probes start before either answers.

The floor case could not fail for the reason it named: its fixture declared 16,
so a census carrying its own copy of 16 passed it. It now plants a seam declaring
20 and a literal 18, the size that is clean under one floor and an offence under
the other.

Two stale wordings from the reverted split: one closure case still said "both
split style modules" over a one-element list.

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

* fix(mobile): buzz the quick-command row when a copy is refused

The last of the seven migrated writes without the error haptic. The row already
said "Couldn't copy" on its own control, in red, for the 1500 ms the toast the
other six show would have lasted, so it never claimed a refused write had landed;
what it had no way to say was anything the thumb still on the button could feel.

Its first test, on the harness its list already uses: the seam rejects when the
pasteboard refuses, and the two cases are the difference between the row that
shows a green check over nothing copied and the row that does not. The list's own
test gains the haptics mock the row's new import needs.

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

* fix(mobile): make the clipboard census require the await its rule depends on

`hasFailureBranch` accepted any enclosing `try` with a `catch`, so the one shape
the census exists to stop passed it: `void clipboard.writeText(...)` inside a
try/catch is an unhandled rejection with a handler three lines above it that can
never run, because the block returns before the promise settles. It now requires
the call to be awaited inside the try's own block, or to carry a `.catch` along
its own chain. The boundary walk stopped only at function and method
declarations, so a `catch` outside an arrow answered for the call left running
inside it; every function-like node ends the search now.

Five cases over snippets read through the same reader, because a `void` write
would have to be committed to be tested against the real tree. Control on a real
site: making the Markdown write un-awaited inside its own try reports it.

Two provenance fixes. The runtime-string delta across C7.2 is two literals, not
one: "Couldn't copy path" took the count from 532 to 533 and "Couldn't copy" took
it to 534. And main's C7.4 made `BRIDGE_CLIPBOARD_MIMES` `['text']`, so an image
mime is a value the schema does not admit rather than a refusal the verb spells
out, with `native.media.pick { source: 'clipboard' }` waiting on C7.6; the web
seam and its test said otherwise. Behaviour unchanged.

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

* fix(mobile): let an unmounted quick-command row emit nothing on a refusal

The haptic I added ran before the mounted guard, so a copy pressed on a row that
then scrolled out of the list, or a sheet closed over it, still buzzed when the
rejection arrived. A buzz with no row to explain it is feedback for nothing, and
the guard was already there for the feedback state one line below.

Red-first: press, unmount, then reject. The success path already guarded first.

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

* fix(mobile): make the clipboard census require a catch with something in it

Any `.catch` property access counted as a failure branch, so two shapes that
handle nothing passed: `clipboard.writeText(text).catch` reads the handler's name
and registers nothing, and `.catch()` swallows the rejection while the caller goes
on to say the write landed. The rule now requires `.catch` to be the callee of a
call carrying at least one argument.

Red-first with both shapes in the snippet reader, the accepting cases unchanged.
Control on the real tree: emptying the notes sheet's handler reports
`MobileSessionSheets.tsx:174`, and restoring it greens.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:49:59 -04:00
Jinwoo Hong 96c1dd8b70 feat(mobile): budget the browser pane's frames and put its inputs on the web seam (OTA phase C, C6.3+C6.4) (#21760)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2)

The pane's frame path is written against React Native's native-prop writer, which does
not exist on React Native Web: a ref there is the DOM node, so both writes throw and the
pane never shows a frame. Three `.web.ts` siblings, each for a measured gap.

- The image and layer writes move out of `mobile-browser-frame-state.ts` into
  `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image`
  on the element RN Web sizes and flips the double buffer with one opacity write per
  layer. The pane still never re-renders while it streams.
- A `background-image` write fires no load event, so the offscreen layer would never
  become visible. The sibling arms the flip from an image decode instead, and the flip
  itself is shared with the native `onLoad` path rather than written twice.
- The data URI keeps the base64 the bridge already carried instead of encoding the bytes
  back into the same string. Measured in this tree against the `buffer` shim the page
  bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against
  under a microsecond for the carried string.

Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and
renders its existing stream-error state otherwise, so a page never waits on frames a shell
without the encoder cannot send. The grant name is a placeholder until C6.1 reports it.

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

* feat(mobile): budget the mobile view's frame area against the bridge cap (OTA phase C, C6.3)

A screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES.
Measured here: the phone's mobile view at the native device scale factor asks for a
390x712 viewport at 2x, which is 1,110,720 device pixels, and at the worst case JPEG has
at quality 72 that is a 807,559-byte message against a 655,360-byte cap — 123% of it.

The `.web.ts` sibling holds the mobile view inside that. The budget is computed rather
than written down: the cap, less an envelope this measures from the frame's own shape at
its widest (435 bytes), is what the base64 may occupy; three quarters of that is the JPEG;
divided by one named worst-case constant of 0.545 bytes per pixel it is an area of 901,271
pixels. The phone lands on a device scale factor of 1.80 and a 654,205-byte message, 99.8%
of the cap. A cap that moved and a budget that did not would be a pane going dark on a
page it could have streamed.

Web view mode is untouched, and byte-identical to the native request: there the frame is a
desktop viewport letterboxed into maxWidth/maxHeight, which the page cannot predict, so C6
ruling 1's drop-the-over-cap-frame rule is its only protection. Native is unchanged.

The constants and the assembly move to a third module because a `.web.ts` cannot import a
value from the file it shadows — the bundler resolves the specifier back to the sibling
itself — and two copies of them would drift.

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

* feat(mobile): put the browser pane's text inputs on the web font-size seam (OTA phase C, C6.4)

The pane has two text inputs, the address bar at 12px and the key row's "Type on page…"
at 14px, and neither went through TEXT_INPUT_FONT_SIZE. In a browser an input under 16px
makes iOS zoom the page on focus and never zoom back, and keyboard-occlusion.web.ts reads
a visual viewport scale other than 1 as "no keyboard" — so one focus would leave the
pane's keyboard lift at 0 for the rest of the typing session. C4.2's failure exactly, on a
screen its census does not walk: that census walks the source-control hub and the review
route, and the pane is in neither until C7 lists a route that mounts it.

The key row's input goes straight onto the seam, whose native value is the theme's body
size, so it renders at the 14px it already did. The address bar is a `.web.ts` split
instead, so native keeps the 12px meta size it has always shown; the input and the label
painted over it move together, or the address would resize on every focus.

The address bar also gets inputMode="url" on the web only. keyboardType is a native enum a
browser does not read, so the page's address bar was falling back to a plain keyboard;
inputMode takes precedence over keyboardType, so it stays undefined on both native
platforms.

One consequence recorded rather than fixed, and pinned in
config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs: the C4.2 census resolves
an import through .ts/.tsx only, never .web.ts, so it reads the native address style that
no browser loads and reports it as an offender. Whoever lists the pane's route either
teaches resolveLocal the extensions the builder already prefers, or moves the address bar
onto the seam natively at 14px.

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

* fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2)

C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the
placeholder this PR landed with while C6.1 was still choosing.

The placeholder was also unusable, which the test added here would have caught:
`GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed
verb and nothing else, so a route declaring `browser.screencast.binary` would have been
refused by the bundle before any shell saw it, and the pane would have taken its
stream-error branch for a reason no screen could report. The name is now checked against
`MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted
spelling as the failing case beside it.

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

* fix(mobile): keep the address field test inside the typecheck ratchet (OTA phase C, C6.4)

`findByType('TextInput')` does not typecheck: a host-component string is not an
`ElementType`, so the file fell out of `tsc -p tsconfig.test.json` and the tests-typecheck
ratchet reported it. Found by reading the ratchet's exit code rather than its piped tail,
which is how it was missed the first time. The element is looked up by its placeholder
instead, and the ratchet is green with 732 test files in the program.

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

* fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2)

Round 1 folds on #21754.

The undecodable arm freed the pending slot without checking whose frame had failed, while
the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints
the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame
3, then frame 3 decodes and the flip is refused because the slot no longer names its
layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has
gone still sends no further frame to recover with. Web only; native never calls this.

Both arms now answer for the frame they were armed with.

The displayable arm's own guard had no test: deleting it left `src/browser/` and the full
suite green, because the case that exercised it settled both decodes and asserted an end
state both orders produce. The harness now settles one decode at a time, keyed on the
source it was given, and the ordered case reds without the guard.

Also: the paint sibling's opacity test claimed "no re-render" while asserting two style
strings, so it is named for what it checks and the claim is counted where React is —
across ten streamed frames the three state setters are called once each, on the mount
frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so
two pre-existing reasons keep their literal em dash instead of a re-serialized escape.

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

* test(mobile): find the address input by its imported type (OTA phase C, C6.4)

`findByType('TextInput')` does not typecheck — a host-component string is not an
`ElementType` — so the file dropped out of `tsc -p tsconfig.test.json` and the
tests-typecheck ratchet reported it. The imported component is what the element is looked
up by now; the react-native mock stands it up as that same string at runtime, so the
lookup is unchanged and the file is back inside the ratchet's program.

Why the earlier run reported 0: the command was
`node scripts/check-tests-typecheck-ratchet.mjs 2>&1 | tail -2; echo $?`, and `$?` after a
pipeline is the exit code of `tail`, which is always 0. The banner line that printed was
the last line of the failure banner, not the success one. Every gate in this branch's
report is now read from the command itself, unpiped.

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

* fix(scripts): read a text input's size from the file the page loads (OTA phase C, C6.4)

The census followed an import through `.ts`/`.tsx` only, while the closure it walks comes
from esbuild, which prefers `.web.tsx`/`.web.ts`. So a style module with a platform
sibling was judged on the half no browser loads. That fails in the direction that matters:
a split whose web half sits under the focus-zoom floor reads as clean because its native
half is on the seam, which is the exact shape the seam exists to catch.

`resolveLocal` now tries the extensions in the builder's own order. The seam comparison
collapses a resolved path onto its module identity before matching, because the seam is
itself a split — `text-input-font-size.web.ts` is where the raise lives — and without that
every binding in the tree would stop naming the seam: deleting it reds both C4 route
closures.

The browser pane's own census flips from pinning its address field as an offender to
expecting none. The two C4 route closures still answer 0 offenders and 0 unresolved, run
with the closure tests enabled.

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

* fix(mobile): bound the frame envelope above every double it can carry (OTA phase C, C6.4)

Round 1 folds on #21760.

The envelope estimate serialized each metadata field as a 16-character double, so the
bound was 435 where a real frame event at its widest measures 516. Since the budget spends
whatever the cap leaves, that 81-byte shortfall was the whole margin: a frame on the budget
would have gone over the cap and been dropped. The bound is now the skeleton plus the
widest a double can print, for the nine metadata keys imported from the protocol module
rather than copied, so a tenth field cannot be added without being paid for.

Two corrections to the fold as written, both measured here.

The widest is 25 characters, not 24. Exponential form tops out at 24
(`-1.7976931348623157e+308`), but ToString only leaves fixed notation below 1e-6, and just
above it a double prints as sign, `0.`, five zeros and seventeen digits:
`-0.0000012345678901234567`. A sweep over four million random bit patterns found 25 and
nothing longer; a deterministic sweep over both forms is in the test.

And the base64 expansion has to count padding. Three quarters of the room claims up to two
characters base64 does not have for an image of 3k+1 bytes, which at a margin the budget
now spends exactly is a dropped frame. The two agree at today's envelope size because the
room happens to divide by four, so this is a latent fix rather than a reproduced one, and
the test pins the discrepancy directly instead of implying it.

Budget moves from 901,271 to 901,161 pixels; the phone's mobile view stays at a device
scale factor of 1.80. Pinning the bound against C6.1's real encoder is C6.5's, once both
are on main; the docstring says so, and says what the bound does not cover: the metadata
object is loose, so unknown keys and web view mode's letterboxed frame are ruling 1's to
drop rather than this budget's to predict.

Also: the pane census listed its own closure by hand, so "no unresolved styles" said the
walk read those files, not that they are the pane's set. It now scans `src/browser` for
every non-test module that renders a `TextInput` and asserts the list matches; a third
module planted there reds it. And the seam's native-consumer pin names the key row and the
address bar directly rather than transitively, matched at the `fontSize` property instead
of anywhere in the file — a file-wide search survives the change, because the import line
does.

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

* test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2)

Round 2 folds on #21754.

Every test for the web paint sibling handed it a `div > div` of its own making, so the
assumption it rests on — that the host's first element child is the one carrying the
frame — was only ever checked against a shape written to match it. React Native Web also
renders an accessibility `<img>` in there, and a release that reorders those children
would keep all of them green while the pane painted nothing.

One test now renders the real component, asks it which child it painted, and checks the
write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the
hand-built cases passing, which is the gap. A second case records what the `<img>` does:
the streaming path writes styles and never props, so it keeps the source it mounted with
for the life of the pane, and that is what a screen reader and the image context menu see.

react-native-web ships no type declarations, so the component comes through
`createRequire`, whose return is `any` at its own signature; the one prop it renders with
is declared rather than asserted, and the file stays inside the tests-typecheck ratchet.

The module docstring also claimed more than the code does. The frame path adds no render,
but a render from any of the pane's other state — address focus, a dialog, the view mode,
zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`,
so both land on the newest frame whether or not it has decoded. Native clobbers the same
way through `setNativeProps`. Said plainly, along with what restores the buffering.

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

* docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2)

pullfrog is right, and the test carried the same wrong claim. The note said the hidden
`<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after
saying a render from the pane's other state passes `renderedFrameSource` as `source` —
and React Native Web derives that image's `src` from the same prop it paints the
background from, so the first such render moves it.

Measured here rather than reasoned about: rendering the real component, writing a frame
imperatively, then re-rendering with a new source moves the `src` and leaves the
background where the imperative write put it. The two halves are now two cases, named for
what each one shows, and the note says the streaming writes never touch it while a render
does — so it holds the frame the pane last rendered with, not the one on screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 05:13:10 -04:00
Jinwoo Hong 6f0fb3fe39 feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2) (#21754)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2)

The pane's frame path is written against React Native's native-prop writer, which does
not exist on React Native Web: a ref there is the DOM node, so both writes throw and the
pane never shows a frame. Three `.web.ts` siblings, each for a measured gap.

- The image and layer writes move out of `mobile-browser-frame-state.ts` into
  `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image`
  on the element RN Web sizes and flips the double buffer with one opacity write per
  layer. The pane still never re-renders while it streams.
- A `background-image` write fires no load event, so the offscreen layer would never
  become visible. The sibling arms the flip from an image decode instead, and the flip
  itself is shared with the native `onLoad` path rather than written twice.
- The data URI keeps the base64 the bridge already carried instead of encoding the bytes
  back into the same string. Measured in this tree against the `buffer` shim the page
  bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against
  under a microsecond for the carried string.

Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and
renders its existing stream-error state otherwise, so a page never waits on frames a shell
without the encoder cannot send. The grant name is a placeholder until C6.1 reports it.

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

* fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2)

C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the
placeholder this PR landed with while C6.1 was still choosing.

The placeholder was also unusable, which the test added here would have caught:
`GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed
verb and nothing else, so a route declaring `browser.screencast.binary` would have been
refused by the bundle before any shell saw it, and the pane would have taken its
stream-error branch for a reason no screen could report. The name is now checked against
`MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted
spelling as the failing case beside it.

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

* fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2)

Round 1 folds on #21754.

The undecodable arm freed the pending slot without checking whose frame had failed, while
the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints
the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame
3, then frame 3 decodes and the flip is refused because the slot no longer names its
layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has
gone still sends no further frame to recover with. Web only; native never calls this.

Both arms now answer for the frame they were armed with.

The displayable arm's own guard had no test: deleting it left `src/browser/` and the full
suite green, because the case that exercised it settled both decodes and asserted an end
state both orders produce. The harness now settles one decode at a time, keyed on the
source it was given, and the ordered case reds without the guard.

Also: the paint sibling's opacity test claimed "no re-render" while asserting two style
strings, so it is named for what it checks and the claim is counted where React is —
across ten streamed frames the three state setters are called once each, on the mount
frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so
two pre-existing reasons keep their literal em dash instead of a re-serialized escape.

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

* test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2)

Round 2 folds on #21754.

Every test for the web paint sibling handed it a `div > div` of its own making, so the
assumption it rests on — that the host's first element child is the one carrying the
frame — was only ever checked against a shape written to match it. React Native Web also
renders an accessibility `<img>` in there, and a release that reorders those children
would keep all of them green while the pane painted nothing.

One test now renders the real component, asks it which child it painted, and checks the
write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the
hand-built cases passing, which is the gap. A second case records what the `<img>` does:
the streaming path writes styles and never props, so it keeps the source it mounted with
for the life of the pane, and that is what a screen reader and the image context menu see.

react-native-web ships no type declarations, so the component comes through
`createRequire`, whose return is `any` at its own signature; the one prop it renders with
is declared rather than asserted, and the file stays inside the tests-typecheck ratchet.

The module docstring also claimed more than the code does. The frame path adds no render,
but a render from any of the pane's other state — address focus, a dialog, the view mode,
zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`,
so both land on the newest frame whether or not it has decoded. Native clobbers the same
way through `setNativeProps`. Said plainly, along with what restores the buffering.

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

* docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2)

pullfrog is right, and the test carried the same wrong claim. The note said the hidden
`<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after
saying a render from the pane's other state passes `renderedFrameSource` as `source` —
and React Native Web derives that image's `src` from the same prop it paints the
background from, so the first such render moves it.

Measured here rather than reasoned about: rendering the real component, writing a frame
imperatively, then re-rendering with a new source moves the `src` and leaves the
background where the imperative write put it. The two halves are now two cases, named for
what each one shows, and the note says the streaming writes never touch it while a render
does — so it holds the frame the pane last rendered with, not the one on screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:30:15 -04:00
Jinwoo HongandClaude ee61e3bd41 fix(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2) (#21735)
* feat(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2)

react-native-web's `Keyboard` is a stub: `addListener` returns a
subscription that never fires and `isVisible()` is always false. A screen
inside the shell's page that waits for `keyboardDidShow` waits for the
life of the document, and the software keyboard covers whatever sits at
the bottom of it. Two C4 screens are text entry at the bottom.

`platform/keyboard-occlusion` is the pair. The native file carries the
source-control hook's logic unchanged, events and clamp and the comment
that travels with it. The web sibling reads `visualViewport`: the layout
viewport keeps its size and the visual one shrinks, so the occluded strip
is `innerHeight - (height + offsetTop)`. `offsetTop` is in it because a
scrolled or pinched visual viewport sits partway down the layout viewport
and the strip below it is not keyboard; dropping the term reds two cases.
It listens on `resize` and `scroll` — the browser scrolling a focused
input into view moves the offset without resizing anything — and reads
once at mount, because a composer opened over an already-raised keyboard
receives no event at all; dropping that read reds a third case.

`useKeyboardAvoidingPadding` is a second name rather than a `Platform.OS`
branch at the call site. Natively it is 0 and subscribes to nothing, so a
composer that asks for it renders exactly as often as it does today;
`KeyboardAvoidingView` has already moved it and padding would move it
twice. On the web it is the whole of the avoidance, that view being
driven by the events this file exists because the page never receives.

No `visualViewport` answers 0 rather than guessing.

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

* fix(mobile): lift the commit bar and the note composer inside the page (OTA phase C, C4.2)

The two consumers move onto the seam. The hub's hook becomes one line and
keeps its name, which is what the hub's state calls the number. The note
composer takes the padding as a style on the `KeyboardAvoidingView` it
already had: natively that is 0, so the prop is `undefined` and the phone
renders exactly what it rendered before; inside the page it is the strip
the keyboard covers, which is the only thing that moves the composer
there.

The census is over both future route closures rather than over the two
call sites: `platform/keyboard-occlusion` is the one module in either
closure allowed to name the stub. Red first at the base commit — run in a
throwaway worktree at `9309350864` rather than by setting the fix aside —
it named `use-mobile-source-control-keyboard-lift.ts` as a subscriber
outside the seam and found the seam's web file in neither closure.

`mounted-bottom-drawer.tsx` is exempt by name, and the census asserts the
exemption is really in both closures so it cannot outlive its subject. It
reads more than a height — `Keyboard.metrics()` for a sheet opened over a
raised keyboard, and each event's `duration` to animate with it — which
the seam does not model, and it sits in C1's, C2's, C3's and C5's closures
too, so moving it is a change to every page rather than to this domain.
Its listeners are inert on the web the same way, which is why the composer
inside it takes its own padding rather than inheriting one.

No render-check case: measured, none of the five registered routes reaches
the seam, the commit bar or the composer, and a headless browser cannot
shrink the visual viewport independently of the layout one anyway. C4.4
carries it.

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

* test(mobile): type the keyboard harness instead of asserting its fields (OTA phase C, C4.2)

The changed-code gate flagged the two `as` casts in the hoisted harness.
A return type on the `vi.hoisted` callback says the same thing and is
checked rather than asserted, which is the shape the host-list route test
already uses.

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

* fix(mobile): read a pinch zoom as no keyboard, and test the clamp (OTA phase C, C4.2 round 1)

Round-1 folds plus CodeRabbit's exemption point.

**A pinch zoom read as a keyboard.** A 2x zoom shrinks the visual viewport
by exactly as much as a half-screen keyboard, so the commit bar and the
composer moved on a page nobody was typing into. A `scale` other than 1
answers 0. Geometry alone cannot tell the two apart and a stored "no
keyboard" baseline would be a heuristic, so a keyboard raised while zoomed
is the accepted rare case rather than a guess. `scale` is read defensively
because older WebViews do not implement it, and taking its absence for
zoomed would answer 0 for every keyboard on them; mutating the guard to
key on absence reds both cases.

**The clamp had no test.** A bare subtraction left all nine cases green.
The case is a visual viewport taller than the layout one, which mobile
Safari reports mid-scroll and which would have pushed the commit bar down
the screen instead of up.

**One guard, where the test reaches it.** `occlusion`'s `viewport ===
undefined` arm was unreachable: the effect returns before calling it, and
the absence case exercised that one. Deleted, and the remaining case says
which guard it proves.

**The census exempts two files, not a directory.** `startsWith('src/platform/')`
would wave through a later `src/platform/*.web.ts` that subscribed to the
stub directly, which is the defect this census exists for. Named exactly,
with a planted subscriber beside the seam as the fixture; restoring the
directory filter reds it.

**And the moved comment claimed an inset it never subtracted.** Deleted.
Correcting a comment that was false where it came from is not a rewrite of
the logic the move carried: no statement moved with it.

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

* fix(mobile): keep the page at scale 1 so the zoom guard is not the keyboard path (OTA phase C, C4.2 round 2)

Round 2's finding changes what the zoom guard costs. iOS auto-zooms on
focus of any input under 16px; both consumers' inputs are 14px
(`typography.bodySize`), and the page's viewport meta set no
`maximum-scale`. So `scale !== 1` was not the rare pinch the guard was
written for, it was every focus — and the seam would have answered 0 on
the one flow it exists for.

The guard stays and the premise is fixed instead: `maximum-scale=1` in
both places the page's meta is written, the built document in
`build-mobile-web-app-bundle.mjs` and the bootstrap `index.html`. iOS
honours it for the focus auto-zoom and has ignored `user-scalable=no`
since 10, so a deliberate pinch still works; the input sizes are
untouched. C4.6 step i is what settles it on a device.

Three test changes and one correction.

The census took a `rootDir`, as `findWebSiblings` does: it planted
`src/platform/other.web.ts` in the real tree while the overrides census
walks `mobile/src` in a parallel worker and would read it as an unlisted
override. It plants under `mkdtemp` now, and writes the two seam files
there too, so the empty result for them is the name exemption working
rather than those files happening not to subscribe.

A case for the ruling itself: scale 2 with a viewport shrunk past what
the zoom explains answers 0. Dropping the guard reds it and the pinch
case together.

`useKeyboardAvoidingPadding` is rendered through the test renderer now
instead of called outside one, with a counter on `Keyboard.addListener`.
Making the native hook return `useKeyboardOcclusion()` reds it at two
calls; the old shape could not see that, because a hook read outside a
component never runs its effects.

Item 4 did not hold as written. `window.visualViewport ?? undefined` is
not a no-op: the DOM declares the property `VisualViewport | null` and an
older WebView omits it entirely, so the coalesce was normalising both
shapes into one `=== undefined` check. Removing it and testing only for
`null` throws on the absent-viewport case (reproduced: `Cannot read
properties of undefined (reading 'scale')`). The coalesce is gone and the
guard names both shapes instead.

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

* fix(mobile): raise the two page inputs to 16px on web instead of pinning the page scale (OTA phase C, C4.2 round 2)

`maximum-scale=1` is reverted from both metas. It fixed the right problem
in the wrong place: Android WebView honours it and iOS ignores it for
pinch, so the cost of stopping an iOS focus auto-zoom was deliberate
zoom on Android, taken from the users who need it most.

The font size is where it belongs. `src/platform/text-input-font-size.ts`
is the app's body size and `.web.ts` is that raised to 16, the size below
which iOS zooms on focus and does not zoom back. The commit bar and the
review note composer take their `fontSize` from it. A phone renders what
it rendered before: the native constant is `typography.bodySize`, so both
style objects are unchanged there.

`Math.max` rather than the literal, so a theme that raises the body size
past 16 keeps its own value.

The zoom guard stays and its rationale is rewritten to say what now keeps
the ordinary path off it: the inputs clear the floor, so a scale other
than 1 means a user pinched rather than an input took focus.

The pin is a unit case because the render check has no route to open yet.
Three assertions and what reds each: the web constant below 16 reds the
first, and a style going back to `typography.bodySize` reds the third,
which reads the two stylesheets as source because a node test resolves
the native sibling and would otherwise pass while shipping 14px to the
web. The overrides census covers the swap itself.

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

* fix(mobile): put every text input in the two closures on the size seam (OTA phase C, C4.2 round 2)

The 16px floor reached two inputs and the rationale claimed a page. Eight
more text inputs in the same two closures still declared 14px, so a focus
on any of them zoomed the document and the occlusion seam — which reads a
scale other than 1 as no keyboard — stopped lifting for the rest of that
session. "A scale other than 1 means a pinch" was false while they were
there.

All eight go through `TEXT_INPUT_FONT_SIZE`, named by the census before
the change:

  src/components/MobileSearchField.tsx:175
  src/components/SmartWorkspaceAdvancedFields.tsx:84
  src/components/SmartWorkspaceSourceField.tsx:137
  src/components/new-worktree-form-styles.ts:125
  src/components/pr-sidebar/MobileLinkPrForm.tsx:120
  src/components/pr-sidebar/mobile-pr-sidebar-styles.ts:299
  src/components/pr-sidebar/pr-comment-composer-styles.ts:20
  src/components/smart-workspace-source-drawer-styles.ts:60

Every one declared `typography.bodySize`, so there was no input carrying
a size of its own to preserve and the phone is byte-identical again. Each
of those style keys was checked for consumers first: all of them are read
by a `TextInput` and nothing else, so raising the web value moves no
other element.

The census is the rule rather than the list. Over both closures it
resolves each `TextInput`'s style to the module that really declares the
size — following a spread, because both seam-served inputs are reached
through `{ ...base, ...list }` and a walk that stopped at the first
module would have called their offence absent — and names anything not on
the seam as `path:line`. A style with no `fontSize` inherits and is not
an offender. Presence precondition: the seam's web file is in the
closure, so an empty list cannot mean a page with no inputs.

Run against the previous head it prints exactly those eight for both
routes; three fixtures under mkdtemp cover the cross-module line, the
spread, and the two non-offender shapes.

The web test's rationale named `maximum-scale=1`, which is gone; it names
the input floor now.

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

* test(mobile): make the input census prove its own enumeration (OTA phase C, C4.2 round 2 addendum)

The offender list only says every text input is on the seam if every text
input was read, and the walk could not tell "this key sets no size" from
"I could not follow this style" — both answered nothing, so a resolution
failure would have read as a clean input and the rule would have gone
quietly vacuous.

`resolveStyleKey` answers three ways now: not found, found with no size,
found with one. `unresolvedTextInputStyles` reports the first as
`path:line (key)`, and the census asserts it is empty for both closures
beside asserting the offender list is.

Measured rather than assumed, which is what the addendum asks for. The
two closures hold 12 `TextInput` elements and 13 style references; none
uses an inline style object and none is without a style prop. All 13
resolve, 12 to `TEXT_INPUT_FONT_SIZE` and one — `styles.disabled`,
combined with `styles.input` on the same input — to a style that really
sets no size. The reviewer picker is in that list at
`mobile-pr-sidebar-styles.ts:300`; it was already on the seam from the
previous commit, which enumerated from the closure rather than from the
review.

A fourth fixture plants both shapes side by side: a style with no size,
which is not an offender, and a style reached through a package import,
which is named.

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

* test(mobile): close three holes in the text-input census (OTA phase C, C4.2 fold 3)

All three of CodeRabbit's findings are on the completeness property the
addendum bought, and all three reproduced before the change: each shape
below answered 0 offenders and 0 unresolved, which is to say it vanished.

Inline style literals. The walk recorded only `object.key` references, so
`style={{ fontSize: 14 }}` was neither an offender nor a hole. Style
props are flattened structurally now — arrays, spreads, `?:`, `&&` and
parentheses down to the expressions that can really land — rather than
walked as a subtree, which had the second bug of descending into an
inline literal's own properties. `&&` is followed because
`[styles.input, disabled && styles.disabled]` is the shape this tree
actually uses; `null`, `undefined` and `false` branches contribute no
style and are dropped rather than called unfollowable. An inline literal
resolves in place, and any other shape — a call, a bare identifier —
lands in the unresolved list.

Source-order precedence. `{ input: safe, ...legacy }` is `legacy.input`
at runtime, and answering direct keys before spreads read `safe` and
called the override clean. Properties are walked in reverse source order
now, direct keys and spreads in one pass, first answer wins.

The seam by binding. `size.text !== SEAM_EXPORT` accepted anything
spelled `TEXT_INPUT_FONT_SIZE`, so a local `const TEXT_INPUT_FONT_SIZE =
14` two lines up passed, and so did an import of that name from any other
module — the regression the seam exists to stop, wearing its name. The
identifier is resolved in the declaring module and accepted only as an
import from `src/platform/text-input-font-size`.

That last one changes what a fixture must say: the existing seam case
spelled the name without importing it, so it plants the seam module and
imports from it now. Six new fixtures, all six red on the previous walk.

Re-measured at this head, both closures: 12 `TextInput` elements, 13
style references, 12 on the seam, 1 sizeless (`styles.disabled`, combined
with `styles.input` on one element), 0 offenders, 0 unresolved.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-20 01:29:30 -04:00
Jinwoo Hong ac024d4f05 feat(mobile): serve the files explorer and preview from the page (OTA phase C, C3.1) (#21710)
* refactor(mobile): take the files screens' router from the handoff seam

Inside the shell's page a screen is one document standing in for one screen, so
a target the page does not render has to be handed back to the app that does.
`useRouteHandoff` is where that decision lives, and its web sibling is the only
thing that makes it; both files screens held expo-router's own `useRouter`, so
on the web the explorer's Back and the preview's Back would post nothing and a
target outside the page would paint Unmatched over the page it is on.

Natively this is the same object — `route-handoff.ts` is `useRouter()` — so no
behaviour moves here, and `back()` stays expo-router's until the navigate-back
verb lands and the seam starts wrapping it.

A census rather than a behaviour test: neither screen's own tests can see the
difference, because a push that is never handed off still works for a target
inside the page. It walks this directory, refuses a value import of
expo-router, and names the two screens that must hold a router so a walk that
found nothing fails instead of passing empty.

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

* feat(mobile): let the shell stand in for the two files routes

Both route files take the index.tsx shape — flag, MobileWebShellScreen, native
screen as fallback — and both gain the `.web.tsx` sibling that shape forces.

Inert until the manifest lists these routes: the shell answers `native-route`
for a route the bundle does not name, which is what `fallback` renders, and the
flag is `__DEV__`-only besides. Listing them waits on C2.3 and C2.5.

The sibling is not a precaution. The manifest defers every route behind
`import()`, so a native-only route module is invisible until the page opens
that route; the render check now opens both and, without the siblings, painted
`expo-modules-core.requireNativeViewManager is not available on web` instead of
the screen. That is also why the two cases render the route rather than
asserting a file exists.

The file path never becomes a path segment: only `hostId` and `worktreeId` are
spelled into the pathname, encoded, and everything else — `relativePath`,
`absolutePath`, `cwd`, `pathText` — is a param, which is how a `/`, a space or a
`..` stays out of the segment vocabulary the bridge holds a route to. The
preview render case proves the round trip on `docs/my notes/readme.md`.

`mobileFilePreviewShellParams` drops a param the normalizer left `undefined`
rather than sending it empty, because the page reads these back through
useLocalSearchParams where `line: ''` and no `line` are different screens. Its
test drives the normalizer rather than a hand-written literal: the literal omits
the key entirely, so it held with the filter removed.

The preview case also records what React Native Web says out loud — BackHandler
is inert on web, so Android back inside the page skips the unsaved-draft
prompt. Named in the assertion rather than filtered out, so closing it is a
change to that line.

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

* fix(mobile): ask about an unsaved draft in the screen, not through Alert

React Native Web's `Alert` is `static alert() {}`. Inside the shell's page that
made Back with an unsaved terminal-artifact draft a button that did nothing at
all: no prompt, because the dialog is a no-op, and no navigation either, because
the code took the branch that shows one. Silently, with nothing on the console.

The prompt is now a row under the header. Not `ConfirmModal`, which every other
confirm here uses: that is a `BottomDrawer`, and C1.9 has Reanimated's animated
styles never reaching the DOM node on WKWebView, so on iOS in the page the
drawer parks off-screen and Back would be dead a second way. This paints the
same on every platform with no animation behind it.

Hardware back is registered natively only. React Native Web's
`BackHandler.addEventListener` logs "BackHandler is not supported on web and
should not be used." and hands back an inert subscription, so the guard never
armed there regardless; the render check asserted that console error on main and
now asserts none. The degradation is real and stated rather than hidden: Android
back inside the page pops the native stack without asking, and the page's own
Back control is where the question lives.

The decision moved to a hook so it is testable without a screen: the prompt also
drops itself when the draft it was about is saved or reverted, which is a state
`Alert` had no way to be in.

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

* fix(mobile): keep expo-haptics' DOM shim out of the page

expo-haptics has a web build, and with no `navigator.vibrate` — iOS Safari,
which is the WebView the page runs in — it fakes a haptic by appending a hidden
`<label><input type="checkbox" switch>` to `document.head`, clicking it, and
removing it, once per call. C1.9 traced a long press that never fired on the
worktree list to exactly that stray click, and the file explorer calls
`triggerSelection` on every row tap, so C3 is the first domain to fire it per
tap rather than per long press.

`haptics.web.ts` answers the same five names with nothing. A phone holding the
page is a phone whose native app is right there with the real haptics, and a
missing tap feedback is worth less than a tap that does not register.

The test reads the shipped bytes rather than the import, because that is the
claim: with the override removed the bundle carries `ariaHidden` and
`pointer: coarse`; with it, neither, nor the `setAttribute("switch"` that does
the clicking. Not `navigator.vibrate` — react-native-web's own Vibration export
calls that and touches no DOM until something invokes it, which cost this test
one wrong red before it was narrowed.

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

* fix(mobile): keep the files routes native when the page could not be given one

A file path is a param, so `/`, spaces and `..` all cross safely — but
`BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 and a Windows long path is not bounded by
anything the user cannot exceed.

The symptom is not the blank document the design predicted, and the correction
matters: `bridge-host.ts` already parses the route against the page's own schema
and drops it to `null` when it fails, so `init` arrives naming no screen and the
page paints "Update Orca to open this workspace" — a wrong message about a fine
app, over a native screen that works. Deciding before the switch instead leaves
the route native, which is where every route starts.

The schema is the predicate rather than a copy of its bounds, so the rule cannot
drift from the half that matters, which is the half the page reads. The same
call also refuses a `worktreeId` the segment rule will not route: `..` survives
`encodeURIComponent`, which is the C1.8 class.

The tests assert the schema really refuses each input before asserting the guard
does, so neither case can pass by being impossible.

This belongs in the shell beside the schema; it is in the files domain while the
contract files are the C2 lane's.

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

* test(mobile): pin what keeps a file path out of the route vocabulary

Seven shapes, one case each rather than a representative: a plain path, a space,
a dot segment, an already-encoded slash, a fragment, non-ASCII, and an absolute
path. Each is checked in the two directions a path travels — the href the shell
writes into the page's history, and the href the page would hand back — for both
the pattern accepting it and the path coming back out of the query unchanged.

The counterfactual is in the file: the same paths spelled as a segment are
refused. Without that, the cases above would hold for a rule that was never
doing any work. Mutating `stringifyRouteHref` to join its query by hand instead
of through `URLSearchParams` fails three of them.

Also fixes two new test files the tests-typecheck ratchet caught: the partial
`react-native` mock needs a typed `addEventListener`, `act` will not take a
callback that returns a value, and `findAllByType('Pressable')` does not
typecheck against `ElementType` — the neighbouring files that do it are
grandfathered, so the tag comparison goes through a helper instead.

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

* fix(mobile): derive the discard prompt instead of clearing it in an effect

Both changed-code gate findings, which the lane had not run until the last
commit. React Doctor is right: the effect that cleared the prompt when the draft
went away adjusted state after a prop changed, so a save landing while the
prompt was up painted one frame still offering to discard nothing. The prompt is
now `asking && hasUnsavedDraft`, which cannot be stale by construction, and the
test that covers it passes unchanged.

The hoisted mock's `as` on a string literal is gone too: the literal narrows on
its own and the tests reassign it, so the holder is annotated instead.

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

* test(mobile): add the files routes to the hybrid shell flag census

The census pins every file that reads `useMobileWebShellEnabled`, because a
reader nobody listed is how a dark feature stops being dark. C3's two routes are
deliberate entries: each has a native screen behind it as `fallback`, and each
is inert until the manifest lists the route.

Found by the full mobile suite rather than by the files subset this lane had
been running per commit.

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

* feat(mobile): serve the files explorer and preview from the page

The last C3 commit: both routes join MOBILE_WEB_PAGE_ROUTES, and the shell
starts rendering the page for them on a phone with the dev flag on.

Grants are not the same for the two, and the difference is the point. Both take
`navigate` (Back pops the native stack, and the explorer's rows open the preview
beside it) and `storage` (the shared components the host layout renders above
them). Only the preview takes `externalLink`: a Markdown preview renders links
and `MobileMarkdown` opens them through the platform seam.

The explorer does not, and measuring is what says so rather than reading. Every
page route reaches `external-link.web.ts` — `/h/[hostId]` and agent-history
included, both granted nothing for it — because the protocol wall in the shared
host layout imports it. So closure membership is not the oracle for a grant; the
question is whether the route's own screens call it, and only the preview's do.
`MobileMarkdown` is in the preview closure and absent from the explorer's, which
the census now asserts in both directions.

Neither route writes a clipboard, so neither takes `native.clipboard.write`;
the census pins that as the absence of both `ExpoClipboard.web.js` and the
clipboard seam, with the tasks closure as the control that the probe can see one
when there is one.

The seam predicate moved into a module both censuses import rather than being
restated per series: two spellings of one rule drift, and this one is a regex.

Red-first: both manifest assertions failed on the new entries before they were
updated, and routing `MobileMarkdown` around the seam fails the preview's census
while leaving the explorer's passing, which is the asymmetry the grants encode.

Closure sizes as the page ships them, extensionless so the `.web.tsx` is what is
measured: explorer 3439 modules / 302 local / 10 under src/files, preview 3667 /
331 / 20.

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

* fix(mobile): read the files route's ids as one value and key the shell on them

Two round-1 findings, both reproduced before the fix.

A repeated query key reaches `useLocalSearchParams` as an array, and the
explorer read `hostId` and `worktreeId` bare. `String(['a','b'])` is `a,b`, so
the template built `/h/host-a%2Chost-b/files/wt-1%2Cwt-2` — a single segment the
bridge's rule accepts, and the shell would open a page for a host nobody has.
Read through `firstParam` now, as the tasks and agent-history switches do. The
preview already went through `singleParam` and is unchanged.

Neither switch keyed `MobileWebShellScreen`, where `index.tsx`, `tasks.tsx` and
agent-history all do. A host captures the grants its session opened with, so a
screen reused across a route change keeps authorising frames under the grants of
the route the page has left; only a remount drops that bridge. Both are keyed on
the route pathname now, with agent-history's reason.

The new route test is the agent-history one's shape. It caught both: the array
case landed on no route at all, because `name` was an array too and the schema
refuses a non-string param value, and the two lifecycle cases saw a prop update
where a remount was owed. It also needs agent-history's `lucide-react-native`
mock, since `firstParam` lives in the source-control barrel.

`name` is now omitted when empty rather than sent as `name=`, matching the two
switches beside it: an absent label lets the panel derive its own.

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

* fix(mobile): confirm a discarded draft with the app's own modal

Round-1 findings 3, 4, 5 and the minor one.

**ConfirmModal, not the bespoke row.** The row existed because C1.9 had
Reanimated's animated styles never reaching the DOM node on WKWebView, which
left every BottomDrawer parked off-screen. C1.10 (`b7c06900e2`, an ancestor of
this branch) fixed that with a dependency array on the mapper hooks, and the
drawer render check now holds it on WebKit as well as Chromium. With the reason
gone the row does not stand on its other merits: `Alert.alert` was modal on
native before the page existed, and the row quietly changed that for phones
too, so the app's own confirm is both the idiom and the closer behaviour.
`MobileFilePreviewDiscardPrompt`, its test and its thirty style keys are gone;
the hook's state machine and its tests are unchanged.

**The encoding test claimed more than it pinned.** Hand-joining the query reds
only three of the seven shapes; `docs/readme.md`, `../etc/passwd`,
`docs/日本語.md` and `/logs/run.txt` are encoding-neutral in the query, whose
pattern half is `[^#\s]*` and admits a slash, a dot segment and non-ASCII
verbatim. Rather than narrow the claim in a comment, the split is now pinned by
behaviour: each neutral shape must survive the query unencoded, each
load-bearing one must not. Moving `docs/readme.md` between the lists fails it.

**The manifest comment named one shared-layout opener and there are two.** The
New Workspace source field, which the sidebar renders on a wide layout, opens a
URL through the seam as well. Both are the shared layout's and every `/h` route
reaches both, `/h/[hostId]` included with no `externalLink`, so the tablet tap
is dead on all of them — recorded here as pre-existing rather than fixed, since
the grants do not move.

**Minor:** the dot-segment case in the guard test now asserts the schema refuses
the route before asserting the guard returns null, as the length case does.

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

* fix(mobile): stop every page drawer logging a BackHandler error when it opens

Round-2 findings.

**The registration belongs to the drawer, and that is where the guard went.**
`mounted-bottom-drawer.tsx` armed `hardwareBackPress` whenever a drawer was
visible and interactive, with no platform check, so the hook's claim to have
dropped that console line held only while its prompt was closed — and every page
drawer since C1 has logged it on open. Platform-gated at the drawer now; the
hook's comment says so rather than claiming the credit.

**Nothing had ever opened a modal in a browser.** The render check next door
mounts both files routes and reads what they paint but taps nothing, so
`ConfirmModal` inside the page — a BottomDrawer, so Reanimated, a portal and a
gesture handler — was unproved. A new render file loads an editable terminal
artifact through the harness's scripted reply, edits it, taps the page's Back,
and asserts the prompt's title is up and no BackHandler line is on the console.
Red first on exactly that line; the prompt itself painted, which is also the
first proof on a browser that C1.10's fix carries a real drawer in the page. A
second case answers Stay and checks the draft survives. Its own file rather than
the render check's, which is at 482 of the 600-line cap; registered in pr.yml.

**The encoding rule was stated wrong.** Two rules decide it and neither is about
paths: the pattern's query half refuses whitespace and `#`, and
`URLSearchParams` is form-urlencoded, so it reinterprets `&`, `+` and a valid
`%XX`. `a+b.ts` reads back `a b.ts` and `a&b.ts` reads back `a`, so both are
load-bearing; `a=b.ts` and `a%b.ts` are not, because only the first `=` splits
the pair and a lone `%` begins no escape. A newline joins the load-bearing list
as the refused shape rather than the altered one.

**The web sibling read its params bare** where the native one uses `firstParam`.
Not reachable — the page only arrives through `init.route`, whose params are
already `Record<string, string>` — but the two files are meant to be one screen.

The preview keys on the pathname alone, and the comment now says why that is
enough: every caller in this tree pushes.

Closures after this: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. The
explorer grew two modules because its web sibling now reaches `firstParam`.

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

* fix(mobile): give the explorer the grants the preview needs, and key on the route

Bot findings, one of them a real gap.

**Pullfrog is right, and my grant oracle was half a rule.** Grants resolve once,
from the route the shell opened: `grantsForRoute` reads `session.routePathname`
and `init.grants.native` carries the answer for that session. The explorer's
rows push to the preview, and because the preview is a page route that push
stays inside the same document — no second `init`. So a preview opened that way
runs under the explorer's grants, and a Markdown link in it was refused by
`notifyExternalLink` with nothing on screen to say why. "Does the route's own
screen call it" was right for a route's own screens and wrong for the routes it
reaches in-page, so the explorer now declares `externalLink` as a transitive
grant, with the comment saying that rather than claiming it opens links. The
census pins the pair as a superset; removing the grant reds it.

**The seam regexes matched one quote style.** A double-quoted `react-native`
specifier walked past both censuses unseen. Both styles now, with the predicate
tested directly for the first time.

**The discard request outlived its draft.** `asking` stayed set after a save or
a revert, so the next edit re-showed the prompt with no Back request behind it.
The request is now dropped when the draft it was about goes, adjusted during
render rather than in an effect — the shape React Doctor named in the round-1
fold. Red first: save with the prompt up, edit again, prompt is back.

**CodeRabbit's keying comment is a correctness point, not the question I
answered.** The page learns its route exactly once, out of `init`, so a
same-path param change — another file in the same worktree — left the shell
mounted and the page still showing the file it was opened on. My comment claimed
"the screen reloads the preview from the param either way", which is true only
with the shell absent. Both switches key on the whole route now, params
included; two tests cover the same-path case and both red on a pathname-only
key.

`build-mobile-web-app-bundle.test.mjs` hit 601 of its 600-line cap on the way,
so the two manifest assertions now share one expected list instead of repeating
it. Closures unchanged: explorer 3441 / 304 / 10, preview 3666 / 330 / 19.

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

* fix(mobile): make the seam test import the module it is testing

Round 3.

**The blocker is mine and the reviewer's diagnosis is exact.** The seam
predicate test imported an absolute path into this lane's worktree. On CI that
module does not exist and it takes the whole `config/scripts` suite down; here
it resolved to the same file by accident, so the test was green against a tree
rather than against the checkout — which is why reverting the double-quote fix
left it passing and the predicate untested. Relative now, and proved: reverting
the fix in place reds both double-quoted cases, which is the first time this
test has failed for the right reason. Every file this PR touches is grepped for
`/Users/` and `orca-lanes`; none carries a path.

**Three comments outlived the grant change.** The two lists became equal when
the explorer took `externalLink`, so "longer than the explorer's" and "declared
with different grants" were both false. Corrected to what is actually true: the
lists are equal and the reasons are not — the preview has its own consumer in
`MobileMarkdown`, the explorer has none and declares the grant because its rows
push to the preview in-page.

**The duplicated serializer is pinned rather than imported.** `shellRouteHref`
lives in `page-bootstrap.ts` beside the page's RPC client and its document
channel, so a native route file importing it would pull both into the app. The
copy stays, and a test asserts the two agree on three routes; dropping the
empty-search branch reds it.

**Recorded, not fixed:** the sidebar `HostScreen` pushes to `/h/<id>/tasks`
through the handoff, which is local, so on a tablet the tasks page runs without
`native.clipboard.write` from any page route and its copy actions refuse
silently. Pre-existing since C2.1 for the worktree list and agent history. Named
in the explorer's manifest comment as the known remaining hop, with the fix
being a handoff rule in its own PR.

The equality pin needed `it.each<BridgeInitRoute>`: the inferred table is a
union whose members carry `?: undefined`, which the ratchet caught.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 15:47:25 -04:00
Jinwoo Hong b6e8b1a7b2 feat(mobile): serve the tasks screen from the page, with its seams (OTA phase C, C2.1 + C2.5) (#21694)
* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)

`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.

Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.

The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.

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

* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)

Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.

Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.

The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.

No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.

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

* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)

The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.

Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.

The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.

The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.

A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.

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

* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)

The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.

A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.

Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.

Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.

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

* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)

The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.

One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.

No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.

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

* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)

`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`react-native-webview` is a native component with no browser counterpart:
importing it runs a codegen lookup that throws, and the route manifest imports
every route, so one such import takes the whole page down rather than one
diagram.

The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.

Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.

Registered in the overrides, whose gate fails on an unlisted `.web.*` file.

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

* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)

The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.

The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.

The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.

The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.

The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.

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

* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)

`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.

Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.

The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.

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

* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)

Four from round 1.

The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.

The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.

`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.

`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.

No parity hash moved: none of these files is in the refactor-parity source
set.

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

* style(config): use endsWith for the clipboard module check

The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.

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

* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)

Five from round 2, two of them real.

The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.

The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.

Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 13:40:26 -04:00
Jinwoo Hong 211821dc17 feat(mobile): render agent session history from the desktop's bundle (OTA phase C, C5.1) (#21596)
* fix(mobile): refuse a page target the shell will not take instead of opening it here

`useRouteHandoff`'s web sibling answered two things — handed off, or push it
locally — and fell through to the local router for three different reasons. Only
one of them is a page route. An href the protocol's own pattern drops and a shell
that answered no are the page reaching past what this shell can serve, and the
bundle carries every route under `app/h`, so the fallback does not paint
Unmatched: it mounts `session/[worktreeId]` on React Native Web inside the shell.

The outcome is now tri-state. A target outside `pageRoutes` is never pushed
locally; the page stays where it is and names the reason once per client, which
is the bound the other page-side reporters take.

Proved in the render check against the real bundle: with the double granting no
`navigate`, "Back to hosts" left the host route for `/` and painted Unmatched
before this, and now stays put, posts nothing and reports no page fault.

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

* fix(mobile): encode the host id the worktree row's navigation actions build

Both targets this sheet offers interpolated `hostId` raw — the C1.2 class the
C1.8 stack fixed at the route files and `web.tsx`, at the last two sites that
still had it. `useLocalSearchParams` answers the decoded value, so a deep-linked
id carrying `?`, `#` or whitespace stops being one segment.

It matters more from C5.1 on. Inside the page these targets go through
`useRouteHandoff`, which matches the pathname against the shell's `pageRoutes`
before deciding anything, and the id is the segment the pattern is reading.

The worktree id was already encoded at both sites; this makes the host id match,
and the new test pins all four targets rather than only the one that moved.

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

* feat(mobile): render agent session history from the desktop's bundle (OTA phase C, C5.1)

The second page route. `agent-history/[worktreeId].tsx` already shipped in the
bundle with its own chunk, so listing it adds nothing to the download and moves
no route count: the switch is `index.tsx`'s, and the shell still decides, because
a bundle naming a grant this app lacks renders the native panel instead.

Its `.web.tsx` sibling is required for `index.web.tsx`'s reason — the native file
reaches OrcaMobileWebShellView, whose requireNativeViewManager runs at import and
takes the whole bundle down in a browser, since the manifest imports every route.

First route with two dynamic segments, so both are encoded. Grants are `navigate`
and `storage`: a resumed session opens the native session screen, the worktree
list now reaches this screen without leaving the page, and `app/h/_layout.tsx`
reads the app's own sidebar width above every page route.

The panel's router becomes `useRouteHandoff`, which is the seam that tells those
two apart: agent history is a page route and is pushed here, the session screen is
not and goes to the shell.

The three writes a resume makes needed no page-side handling and have none. What
they needed was a test that the descriptor's handling survives the extra hop, so
each is run through the bridge and against the same fake directly and the two
verdicts compared: a refused create raises the host's message, and a lost reply or
a shell disposed mid-flight stays delivery-unknown rather than becoming a failure
a user would retry blindly. No golden covers those three.

The flag census grows its first entry since C1.3, which is what it is for.

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

* test(mobile): pin the agent-history Back button to the shell handoff

C5.1 wired this button by swapping the panel's `useRouter` for `useRouteHandoff`;
nothing else was needed, because `RouteHandoff` is the router's own shape and the
seam's web sibling decides `back`. So this commit is the test that would have
caught the wiring being absent, not a fix.

Red before the merge, green after, on the same four cases: on `e897e8123a`, where
`back` was still expo-router's own spread member, 3 failed and 1 passed — the one
that passed is the local-pop case, which is the branch C2.2 did not change. After
the merge brought in C2.2's `back`, all 4 pass. The pre-merge run named the notify
by its literal `'navigate-back'` because the contract constant did not exist yet;
it is the same string `BRIDGE_NAVIGATE_BACK_NOTIFY` holds, so the two runs asked
the same question.

Both module substitutions are the builder's own rather than conveniences: the web
bundle resolves `route-handoff` and `client-context` to their `.web` siblings, so
mocking each to its sibling gives this screen the module graph it has inside the
page. The frames are read off the port pair's lane rather than off a spy, and one
case asserts a frame crossed at all before either absence is read as an answer.

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

* test(mobile): compare the resume's second write, not only its first

Round 1, finding 1. All three `resumeAiVaultSessionInTerminal` cases settled the
create (`ai-vault-resume-launch.ts:158`), so `terminal.send` had never crossed the
bridge and the half of the resume that types the command into the pane was
uncompared. Three cases now drive both writes: a refused send raises the host's
message, an accepted send reporting `accepted: false` in-band says "Terminal input
is locked", and a send the host takes resolves — the last one being the presence
precondition, since a run that failed at the create would give the same shape of
verdict as one that failed at the send.

Reading `requests[1]` straight after settling the create finds nothing on the
bridged leg: the second write is made only once the first settles, so it is two
more lane round trips away. `nthRequest` waits instead, and says how many it saw
when it gives up, so this cannot pass by proving the opposite of what it says.

The locked reply is `{ send: { accepted: false } }`, not `{ accepted: false }`:
the reader is `reply.send?.accepted !== false` (`review-terminal-reply-schema.ts:65`),
and the flat shape resolves rather than throwing. Written the flat way first, both
legs agreed on "(resolved)", which is the comparison doing its job.

Also finding 1's second half: the file docstring claimed every case runs twice and
differences the verdicts, which was false for the dispose case — a fake RPC client
has no door to shut, so there is no native run to compare against. The docstring
now says so and the case carries the same note.

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

* test(mobile): pin what encoding cannot save about a dot-segment id

Round 1, finding 2. The route's docstring listed `/ ? #` and whitespace and the
encoding test covered five ids of that kind, which together implied encoding makes
any id safe. It does not: `encodeURIComponent('..')` is `'..'`, so the pathname
reaches `BRIDGE_ROUTE_PATHNAME_PATTERN` intact, fails the lookahead that stops a
climb out of `/h/` (`bridge-caps.ts:68`, read through `bridge-envelope.ts:117`),
and the shell answers with `reportShellFailure` — a failure screen where the route
would otherwise have rendered the native panel it already has.

Pinned, not fixed, and the docstring now says which. `app/h/[hostId]/index.tsx`
builds its pathname identically and has the same hole, so this series fixing one
of two call sites would leave the shape behind and stop describing it. The new
case asserts both halves — the segment survives encoding unchanged, and the
pattern refuses the pathname — so a later change that starts encoding dots fails
here and has to say which screen it wants instead.

Characterisation, so it was green on the first run rather than red: the claim is
about behaviour that already ships, and the value is that the refusal is on record.

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

* test(mobile): mount the agent-history route in a real browser

Round 1, finding 3. Nothing rendered this route's real module graph anywhere. The
unit tests mock react-native, safe-area, svg, lucide and the icon assets away —
they have to, since react-native is Flow source vitest cannot parse — so a
component in this closure with no web build would have reached a device before it
reached a test. The render check is the only place the graph meets React Native
Web, and this route was not in it.

Two cases. The first mounts the route from the shell double and reads the screen:
"Agent Session History" and the worktree label the params half carried, no fault,
no console error, no CSP refusal, and the URL the page wrote for itself. That also
proves `init.route.params` end to end on a route that has a dynamic segment too,
which §1 of the design claimed and nothing checked.

The second pins the chunk. C5 is the first series whose success path pulls a
second chunk after the first paint, which on iOS goes through WKURLSchemeHandler
under `script-src 'self'`. The chunk is named from the builder's own route map
rather than guessed from the bytes, and asserted absent from what the first route
loaded, so this says the route came over the wire now.

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

* docs(mobile): say the real lifetime of the route-handoff refusal set

Round 1, finding 4. The comment claimed one line per reason "for the life of one
client", borrowing `createPageDiagnosticReporter`'s bound. The set is built inside
the `useMemo` keyed on `[client, router]`, so it is per hook instance: in practice
the memo is not recomputed, because `useRouter()` is expo-router's module
singleton and the page holds one client, but every screen calling the hook gets
its own set and a reason can be reported once per screen rather than once per
document.

Says that now, and why it is not tightened: a per-module set would outlive the
page's client, which is the lifetime the rest of these reporters are scoped to,
and there is no document-wide reporter to join without reaching into a contract
file the C2 lane owns.

Records the other half of the finding too, which came back confirmed rather than
changed: `console.warn` is right here. It is the vocabulary `page-bootstrap.ts:35`
already writes in, and a `fault` notify would be wrong twice — the shell drops the
generation on a page fault, and a navigation the page declined is not a failure.

Comment only; no behaviour change, 25 navigation tests unchanged and green.

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

* fix(mobile): wrap every router member that takes a target, not three of five

Round 2, finding 1. `...router` hands through everything this file does not name,
and two of the members it did not name take an href: `navigate` and `prefetch`.
`navigate` to a route outside `pageRoutes` went straight to expo-router and pushed
it into this document — the hole the tri-state exists to close, reopened under a
name nobody had looked at. No call site uses it today, which is why it shipped.

`navigate` is now wrapped exactly as `push` is: which of push-or-collapse it does
is a decision about this document's stack, and a target outside this document has
no such stack.

`prefetch` is decided the other way, explicitly. It is the one target-taker that
must never reach the shell: a prefetch is a background load, `navigate` is the
only thing the shell can be told, so handing one over would open a screen nobody
asked for. A route this document serves is prefetched here, which is what the
per-route chunk split makes worth doing; every other one is dropped without a
line, because a warm-up that did not happen is not a failure to report.

The docstring's "four members that can leave this document" is now five wrapped
members and a rule for which is which.

A list would rot, so the pin is derived: `HrefTakingRouterMember` reads the
parameter tuple of every member of `RouteHandoff` and `WRAPPED_HREF_MEMBERS` is
asserted equal to it in both directions. It reads the tuple rather than testing
assignability because `() => void` is assignable to `(href: RouterHref) => void`,
which would make `back`, `dismissAll` and `reload` target-takers and prove
nothing. Checked both ways: dropping `prefetch` from the list fails the compile
with "Type 'HrefTakingRouterMember' does not satisfy the constraint", and the
union resolves to exactly the five, with `back` and `setParams` outside it.

The pin is in the product module because `mobile/tsconfig.json` excludes tests.
The runtime test asserts each wrapped member is not the router's own function and
that `setParams` still is, so a hook that wrapped everything fails too.

Red first: 4 of the new cases fail against the previous file, 33 pass now.

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

* test(mobile): say per hook instance in the title too, not per client

Round 2, finding 2. The source comment was corrected in round 1 and this test's
title was not, so the two disagreed about the bound the refusal set actually has:
the set lives in the `useMemo`, so it is per hook instance, and a title claiming
per client is the stronger promise the code does not make.

Title only; the case and its assertions are unchanged.

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

* docs(mobile): say what the agent-history render case does not cover

Round 2, finding 3. The docstring claimed the case is where the panel's closure
meets React Native Web, which overstates it. The shell double answers no RPC, so
the session scan fails and the panel paints its "Unable to Load" state: the
session list, its rows, the resume button and the scope tabs never render, and a
render-time gap inside any of them would pass this check.

Now says both halves — import-time evaluation of every module in the closure and
the panel's own chrome are covered, the list subtree is not — and names what
covering the rest would take: a double that answers `aiVault.listSessions`, which
is a different instrument and would put domain behaviour in this file.

Text only. This case moves to its own file on the extracted harness after the
merge with #21592; the corrected text travels with it.

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

* fix(mobile): import the handoff module once in its own test

My round-2 fold added `WRAPPED_HREF_MEMBERS` as a second import of
`./route-handoff.web`, which `import(no-duplicates)` fails in the focused-plugins
pass of the changed-code gate. Joined to the existing import below the mocks,
which is where an import of the module under test has to sit in this file.

Found by running the changed-code gate rather than by review: mobile tsc, whole
tree oxlint and the suite were all green with it.

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

* test(mobile): give agent-history its own render file on the extracted harness

The render check gained browser cases from three domain series at once, each
under the `.mjs` cap of 600 counted lines alone and no two of them together: at
39b15e395d the file was 781 raw lines and clean, main's was 786 and clean, and
their merge was 857 raw and 623 counted, which is the CI red on #21596. C1.10
extracted the harness so a domain gets a file instead. This is C5's, and the
render check is back to 639 raw lines and clean.

Five cases. The two that moved — the route mounts and paints, and its chunk is
fetched on navigation — plus three new ones.

Back, twice. With `navigate` granted the page's Back control posts exactly one
`navigate-back` notify and the page does not move; with the grant withheld the
same tap reaches the same handler and posts nothing. The pair is the point: the
document holds the single history entry the entry wrote with `replaceState`, so a
Back this page served itself would also have gone nowhere and looked identical.
This is the first proof of that handoff in a browser rather than against a mocked
router.

And a row. The harness's new `replies` lets the double answer named methods, so
the panel now renders a real session instead of its "Unable to Load" state, which
is the render-time gap the round-2 docstring conceded. Assertions are on the row's
own text and message count, plus the absence of both silent states — the scan
failing, and a session out of scope.

Replies lifted from the corpus, and one of them needed two scenarios. The session
and worktree lists are `aivault-history-screen-listed`'s. Its `status.get` is a
capability list alone, and the first run painted "Update Orca on your computer":
`HostProtocolGate` above every host route reads the same method for fields that
scenario never scripts. The status reply merges those from
`transport-host-status-gates-ready`, and the comment says why two.

`wt-history` is load-bearing, not incidental. The panel opens on the `workspace`
scope and filters by paths from the worktree list, so on any other worktree these
same replies paint "No agent sessions" — green, and proving nothing.

Registered in the `mobile_web_app` job beside the drawer check, which is the job
that makes a missing mobile install fail rather than skip.

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

* fix(mobile): encode the host id at every href the host page builds

Pullfrog on #21596. My earlier commit fixed the two hrefs in the row's navigation
sheet and stopped there; five more sites in the same page interpolate the decoded
id raw — Accounts and Tasks in both header layouts
(`host-screen-header.tsx:188,204,307,318`) and the session target
`openWorktreeSession` builds (`use-host-worktree-actions.ts:192`).

Same C1.2 class. The persisted host store admits any non-empty id and both
`useLocalSearchParams` and the store answer it decoded, so one carrying `/`, `?`,
`#` or whitespace stops being the single segment `matchesRoutePattern` reads.
Inside the page that decides where a tap goes, because the handoff matches the
pathname against the shell's `pageRoutes` before choosing this document or the
native stack.

A census rather than five more assertions: the failure is a habit, not a bug —
each of these was written by copying the one beside it, and the seventh will be
too. It counts `/h/${...}` interpolations across the host page's four source
files and requires `encodeURIComponent` at each, with a presence check so it
cannot pass on an empty list.

Two sites are exempt and stay raw: `use-host-worktree-actions.ts:171` and
`app/h/_layout.tsx:100` compare against a pathname the router answers rather than
building a link, so encoding them would change what a comparison matches instead
of what a tap opens. The exemption is subtracted by count rather than matched
away, so a file that lost its comparison and gained a raw target does not come
out even.

ONE BEHAVIOURAL EDGE, named rather than fixed. `navigateFromHostList` short
-circuits when `pathname` equals the target minus its query. That comparison now
has an encoded target on one side and whatever `usePathname()` answers on the
other, so for a host id that needs encoding the short-circuit stops firing and a
tap on the screen you are already on re-navigates instead of doing nothing. It is
a redundant navigation, not a wrong one, and the guard at :171 is unaffected
because it compares against the same raw form it always did. Left alone because
fixing it means deciding what `usePathname()` returns for an encoded segment,
which is a question worth its own change.

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

* fix(mobile): guard the optional host id the session target encodes

The commit before this one did not typecheck: `useHostWorktreeActions` takes
`hostId` as `string | undefined`, and `encodeURIComponent` does not. I committed
on a green test run without waiting for `tsc`, which is my error and the reason
this is a second commit rather than an amend — the lane forbids rewriting a
commit that exists.

`?? ''` rather than a cast or a non-null assertion. An absent id then builds
`/h//session/...`, an empty segment the shell's own route rule refuses, instead
of the string "undefined", which that rule would accept as a host genuinely named
undefined. Every other member of this hook already guards the same field.

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

* fix(mobile): keep the agent-history route native when the bridge would refuse its id

CodeRabbit on #21596. The persisted host store admits any non-empty id, so `.` or
`..` reaches this route, survives `encodeURIComponent` unchanged, and fails the
bridge's own segment rule. The route handed it over anyway: `bridge-host.ts`
parses the route against `BridgeInitRouteSchema`, drops it to null when it fails,
and the page answers an `init` naming no screen with "Update Orca to open this
workspace". A failure screen, in place of the native panel sitting right behind
this switch.

The route asks the schema first now and stays native when the answer is no, which
is where every route starts. Mirrors C3.1's call for the files routes
(`69e618e19a`), including its reason for using the schema rather than a copy of
its bounds: two spellings of one rule drift, and the half that matters is the
half the page reads.

The pin moves with it. It characterised the refusal before — asserting the
pathname was built and that the pattern rejected it — and now asserts the native
render, for a dot host id and for a dot worktree id, which is the other segment
and was never covered.

`app/h/[hostId]/index.tsx` has the same hole and is not fixed here, as asked: it
builds its pathname the same way and hands it over unchecked. When C3.1 is also
on main the two guards and `mobile-file-shell-route.ts` belong in one module
beside the schema, rather than a third spelling of a one-line call.

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

* fix(mobile): forward navigation options through the wrapped router members

CodeRabbit (major) on #21596. expo-router's `push`, `replace`, `navigate` and
`dismissTo` are `(href, options?)`, and the wrappers took the href alone. A local
push asking for `{ withAnchor: false }` reached the router without it, so inside
the page the router did something other than what the caller wrote — silently,
because dropping an optional argument is not a type error.

Each wrapper forwards both on its local branch now. Nothing in this tree passes
options today, which is why it went unnoticed and exactly why it needed pinning:
the first caller to pass one would have had it dropped without a word.

Options do not cross to the shell, and the docstring says so rather than leaving
it to be discovered. The `navigate` notify carries an href and nothing else, so a
target handed over is opened by the native stack on that stack's own terms. That
is the right shape — the options describe a push inside a document the shell's
target is not in — but it is a loss, and a loss worth naming.

Four existing assertions moved from `toHaveBeenCalledWith(href)` to
`(href, undefined)`. That is what the router now receives when a caller passes
none, and expo-router reads an undefined second argument as absent; the comment
above them says so, so the next reader does not take it for a bug.

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

* docs(mobile): count the wrapped members the way the returned object does

Pullfrog on #21596. The header still described the set as it stood before
`860577cc30`: "the four members that can leave this document", "the three that
carry a target", "the other three". There are six wrapped members now and five
carry a target, so every count in the paragraph was one or two short and a reader
checking the object against the prose would have found neither explained.

Now says six wrapped, five target-takers named and pinned by
`WRAPPED_HREF_MEMBERS`, four decided by the shell's route list, `prefetch` the
fifth and decided differently for a reason the member's own comment gives, and
`back` the sixth carrying no target at all.

Comment only; 36 navigation tests unchanged and green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 05:15:46 -04:00
Jinwoo Hong 57fdf68ab3 feat(mobile): let the page hand a link to the device through the shell (OTA phase C, C2.3) (#21597)
* feat(mobile): answer externalLink on the shell side of the bridge (OTA phase C, C2.3)

A page has no way to open a URL outside itself: `Linking.openURL` is the
app's, and inside the shell the page is a document that cannot reach it. Adds
`notify { name: 'externalLink', url }` and a new grant name of its own in
`MOBILE_WEB_SHELL_GRANTS`, rather than a verb of `navigate` — `navigate`
opens a screen this app carries, this hands a URL to whatever the device
opens it with, and a shell implementing one and not the other is a real shell
the route policy has to be able to describe.

`https:`, `http:` and `mailto:` only, and broad inside that: any host, any
path, because a grant that named GitHub would grow a row per provider. The
rule is parsed rather than prefix-matched, since a scheme is what a URL
parser says it is and `startsWith('https:')` reads one out of
`javascript:alert("https://x")`. It is enforced at the frame as well as at
the page's call site, so a page that skipped its own check still cannot reach
the device handler. Bounded by the route href cap, per the ruling.

`BRIDGE_PROTOCOL_VERSION` is not bumped. Inert until a consumer exists: no
call site and no barrel is touched here.

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

* test(mobile): plumb externalLink through the bridge hook probe (OTA phase C, C2.3)

The hook's own suite builds its caller options inline, so the new required
option made it stop typechecking. `tsc -p tsconfig.json` excludes test files;
only the tests-typecheck ratchet saw it.

Adds the case that goes with it: a URL the page hands over reaches the
caller that can leave the app, and nothing reaches the navigate path.

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

* feat(mobile): let the page post an externalLink over the bridge (OTA phase C, C2.3)

`notifyExternalLink(url)` on the page client, gated on the `externalLink`
grant and on the same scheme rule the frame enforces.

Checked twice on purpose. Nothing crosses back for a notify, so the boolean
is the only answer a tap gets: a page that posted a URL the shell's reader
then dropped would report "opened" into a frame nobody acted on, which is
precisely the dead tap the grant exists to rule out.

False before `init`, false after `close`, and never a throw — the callers are
tap handlers with no catch around them.

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

* feat(mobile): add the external-link seam the tasks call sites will use (OTA phase C, C2.3)

One module with a `.web.ts` sibling, which is the shape every platform gap in
this bundle already takes. Native is `Linking.openURL` with the rejection
swallowed, because every caller is a tap handler and `openURL` rejects for a
URL no installed app claims. Web posts the `externalLink` notify after the
same scheme check the frame enforces, names its refusals and throws nothing.

The opener is published by the entry rather than read from context, for the
reason `publishPageStorage` is: the callers are plain functions in render
trees the provider does not wrap. A document that published none refuses
every URL, which is the right answer for a page with no shell.

Registered in `web-overrides.json`: inside the shell's WebView, react-native
-web's `Linking.openURL` opens the URL in that WebView and replaces the page
rather than handing it to the system browser.

No call site and no barrel is touched. The consumer PR swaps them onto this.

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

* docs(mobile): state the real reason the page cannot open its own links (OTA phase C, C2.3)

The override reason claimed react-native-web's `Linking.openURL` opens the
URL in the shell's WebView and replaces the page. It does not. Read from
react-native-web 0.21.2: `openURL` calls
`window.open(url, '_blank', 'noopener')` and resolves whether or not anything
opened; only a `tel:` URL assigns `window.location`, and none of the three
allowed schemes is one.

The true failure is the worse one and the better argument for the verb. Both
shells refuse `window.open` outright, measured in their own sources: iOS sets
`javaScriptCanOpenWindowsAutomatically = false` and returns nil from
`WKUIDelegate`'s `createWebViewWith`; Android sets the same flag false, calls
`setSupportMultipleWindows(false)` and returns false from `onCreateWindow`.
So nothing opens, `openURL` resolves anyway, and the native path reports
success into a tap that did nothing — precisely the dead tap the grant exists
to rule out.

The seam's own comment now says the same, so the two files cannot drift.

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

* fix(mobile): report a URL the phone could not open (OTA phase C, C2.3)

The shell swallowed `Linking.openURL`'s rejection. Nothing crosses back to
the page for a notify, so an open that failed — a `mailto:` on a phone with
no mail account — was silent on both sides. That is the one dead tap this
verb does not rule out, and it was the only one with no record at all.

Warned with the URL and the error, in the shape the two neighbouring reports
in this screen use, and still not rethrown: this runs on the native frame
handler. The contract now asks for the report rather than only for the
absence of a throw.

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

* fix(mobile): forward the URL the parser read, not the string the page sent (OTA phase C, C2.3)

The scheme check reads the protocol through the WHATWG parser, which strips
tab, LF and CR from anywhere in a URL and trims leading C0 and space before
the scheme is visible. So `ht\ntps://example.com`, `https://example.com/a\r\n`,
`  https://example.com/a  ` and `https:example.com` all passed the check, and
both sides then forwarded the original string. Not a scheme escape — the
parser had already decided the scheme — but the device handler was given a
URL the check never looked at, which is a dead tap through an allowed URL.

`readBridgeExternalLinkUrl` answers the parsed href, and the page posts it
and the host forwards it. Normalizing rather than comparing, because
`https://example.com` differs from its own href by a path slash: refusing
what differs from its normalization would refuse an ordinary URL.

The cap now applies to the normalized form as well as the raw string, since
percent-encoding expands and a string inside the cap can leave it.

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

* test(mobile): exercise the seam's default opener instead of a published one (OTA phase C, C2.3)

The case named for a document that published no opener published one first,
and `post` is module state every earlier case had already set, so the default
at the top of the module was never the thing under test. The only assertion
was `not.toThrow()`, which passes against any implementation.

`vi.resetModules()` and a fresh import, and the refusal reason is asserted.
Confirmed to discriminate: flipping the default to `() => true` turns this
case red and leaves the other three green.

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

* test(mobile): build the openURL rejection per call, not at mock setup

The case added for a failed open used
`mockReturnValue(Promise.reject(failure))`, which builds the rejected promise
at setup time. Nothing attaches a handler until the notify frame arrives
several awaits later, so the suite reported an unhandled rejection and exited
1 with every test passing — a red run that reads as green in the counts
alone.

A fresh rejection per call closes the window, and `openUrl` is reset between
cases so the mock cannot leak into one that does not expect it.

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

* fix(mobile): guard the openURL failure that escapes a tap handler (OTA phase C, C2.3)

`Linking.openURL` validates before it returns anything: `_validateURL` is an
`invariant` that throws for an empty string (react-native 0.83.10,
`Libraries/Linking/Linking.js:117-123`). So the seam's `.catch` was attached
to a promise that, in that case, never existed, and the throw went straight
through a tap handler — contradicting the module's own claim to be safe in
one.

Both failure modes are now caught and reported, and neither is rethrown. The
test double validates the way the real module does, because a mock that only
rejects cannot reproduce the failure that escapes.

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

* refactor(mobile): collapse the duplicate openURL wrapper onto the seam (OTA phase C, C2.3)

`mobile-pr-url.ts` was the seam's native body already, byte for byte, written
before it. It now re-exports the seam under its own name, so the empty-URL
guard and the failure report reach its four callers too.

Nothing changes natively: the seam's native form is what those callers were
running. On the web they would now post the notify instead, which is the
behaviour they should have had; it is unreachable today, since none of them
is in a page route's closure.

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

* test(mobile): read externalLink end to end over the port pair (OTA phase C, C2.3)

The pair harness grew `externalLinks` and nothing read it. This is the
`navigate` twin's shape, over the six normalization inputs: what the page put
on the wire and what the shell forwarded are the same strings, read back off
the frames rather than recomputed, and nothing reaches the shell's client.

Two halves on purpose. The page normalizes before it posts, so over the
client the host only ever receives an already-normalized URL and forwarding
it raw would pass — the frame injected straight into the host at the end is
what holds the host to the rule on its own. Confirmed to discriminate:
reverting the host to forward `message.url` turns that assertion red.

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

* style(mobile): match the neighbours on three small inconsistencies (OTA phase C, C2.3)

Three of a kind, none behavioural:

- the notify guard's docstring had a 109-character line; reflowed
- the shell's could-not-open warning passed three arguments where the three
  other reports in that screen pass two; it now passes `{ url, error }`
- the native seam's suite built its rejection with `mockReturnValue`, which
  is the shape `52191bab02` removed elsewhere; it now builds one per call,
  through the same double that validates the way the real module does

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 03:33:41 -04:00
Jinwoo Hong e2afb5eef9 feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) (#21503)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

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

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

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

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

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

* feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4)

Three gaps the census named, and the last of them is why the page needed a second
grant.

`expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with
an empty array and the list painted "Host not found" over the host the shell had
just opened it for. `init` gains an optional `host`, and `host-store.web.ts`
serves it: the profile the screens read, with no device token and no public key,
because the bridge already carries the connection those would have opened. The
native writes it cannot make — pairing, renaming, recency — settle rather than
throw, since recency orders a list the page never shows.

AsyncStorage's web build is `window.localStorage`, and the page has none worth
having: Android turns DOM storage off and on iOS the origin host is the session
id, so a pin set in the page was gone on the next remount. The builder aliases
the module to a page store whose values are the app's own — `init` primes the
allowlisted keys, a write is applied locally and posted over a new `storage`
grant, and the app is where it lands. The allowlist is two keys and is the whole
fence: everything the app stores shares one namespace, the hybrid shell flag
included, so a page that could write any of it could turn the feature on for a
build that never offered it. A key outside the list is refused and, crucially,
not kept locally either — a pin that looks set and is not is the failure the
grant exists to avoid.

The bridge host is built only once both have been read, because `init` is
answered once per `ready` and carries them: a host that started without them
would have to be torn down to carry them, and the list would already have mounted
against a host it could not name.

`Alert.alert` on a failed host removal is a silent no-op in React Native Web, so
inside the page that failure had no surface at all. It routes to the error the
list already shows, on both platforms.

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

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

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

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

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

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

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

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

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

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

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

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

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

* fix(mobile): write the storage-write ref after the commit

The last render-phase ref write in this hook joins the commit-phase effect.

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

* fix(mobile): name the confirm setter this callback already calls in its deps

A `useState` setter is stable, so the identity of the callback is unchanged; the list now
says what the body reads. Reported on the line this branch rewrote.

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

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

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

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

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

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

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

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

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

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

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

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

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

* fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4)

Five, from one round of review.

A removal that fails no longer writes the identity error: that one is an early return
over the header, the list and the overlays, with nothing to dismiss it and nothing left
to render the confirm it re-opens. It goes to a dismissible line above the list instead,
on both platforms, cleared by the next confirmed refresh.

`init` reads the allowlisted keys on every answer rather than capturing them at mount, so
a document that reloads inside one mount is primed from after its own writes. The read
stays synchronous: the page refuses every member until `init` lands and the golden
recorder mounts a screen in the same turn it drains one, so a promise here moves the
first render of every bridged replay.

A profile read that rejects is now a shell failure with a diagnostic instead of a `ready`
session with no host behind it and a page asking forever. The page bounds a value by the
envelope's own constant rather than caching what the wire drops. And a write is held to
the keys this page was handed, so one host's page cannot rewrite another's pinned list.

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

* test(mobile): state the two test fixtures' types instead of asserting them

The casting gate reads a `SAFETY:` rationale off the line directly above the assertion,
and a wrapped comment puts a comment there instead. Two of the four were not assertions
worth keeping at all: a hoisted fixture says its own type, and the router comes from the
mock the file already installs.

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

* test(mobile): assert the two fixtures in place, not through a widened binding

`const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate
refuses, and rightly: the evidence is discarded and then invented again. The assertion
belongs at the literal, with its rationale on the line above it.

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

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

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

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

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

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

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

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

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

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

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

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

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

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

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

* fix(mobile): name the key a refused page write reached for

The last diagnostic still falling through to the line about a view outliving its
host, on the branch that added it. The key is the evidence: it says which host's
pinned list the page was reaching into.

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

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

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

* test(mobile): give the ref-refresh probe this branch's storage writes too

Same merge, one branch further: a probe here also collects what the page asked
the screen to write.

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

* style(mobile): format the web shell route entry

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

* fix(mobile): hand the page the app's storage as it stands, not one init late

The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes
two of them from its own native screens. The shell's map was only re-read after a ready, so a
native write between two readies reached the init after next: the drawer opened on the repo the
user left. The map is now module-scoped and every writer of an allowlisted key notes it as it
writes, so the init that answers a ready already carries it. The store read only seats the map,
and a read that started before a write no longer puts the older value back.

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

* test(mobile): find the banner's dismiss without an assertion

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

* refactor(mobile): keep the storage mirror in the storage tree

The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the
dependency the wrong way round: the shell is what is built on the app's storage, not the other way.
The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller
names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour
change.

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

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

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

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 22:20:49 -04:00
Jinwoo Hong ac4dc6599b feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

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

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

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

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

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

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

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

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

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

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

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

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

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

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

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

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

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

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

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

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

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

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

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

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

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

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

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

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

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

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

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

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

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

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

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

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

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

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

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

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

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

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

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

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

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

* style(mobile): format the web shell route entry

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

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

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

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:53:58 -04:00
Jinwoo Hong b8f3b1ec00 feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) (#21501)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

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

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

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

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

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

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

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

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

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

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

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

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

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

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

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

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

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

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

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

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

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

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

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

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

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

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

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

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

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

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

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

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

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

* style(mobile): format the web shell route entry

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

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

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

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:21:48 -04:00
Jinwoo Hong d043cfbbee feat(mobile): the page mounts on the shell's init, with the client injected and a fault boundary (OTA phase C, C1.1) (#21500)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

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

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

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

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

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

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

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

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

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

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

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

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

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

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

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

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

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

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

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

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

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

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

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

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

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

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 20:45:03 -04:00
Jinwoo Hong 209d2d8df6 build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) (#21475)
* build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5)

The page bundled as one 8.16 MB script because every route was a static
import. The route manifest now defers each screen behind `import()`, the
build is esm with splitting on, and the document loads the entry as a
module. What the browser parses before the first route can paint drops
from 8.16 MB to 908 KiB; the whole page still weighs the same.

Two budgets hold it: the chunk count, which catches a split running away,
and the bytes the entry reaches by static import, which catches it
collapsing back. The second is the one that matters, and it is measured
from esbuild's metafile because only that says which import is static.

The RequireContext stays synchronous, since expo-router reads keys() to
build the route tree before anything renders. A lazy module cannot answer
`unstable_settings` or `ErrorBoundary`, which expo-router reads off the
namespace, so a test holds that no route in the subtree exports either.

The render check now waits for the route's own text: the entry's mount
signal lands while the route chunk is still being fetched.

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

* fix(mobile): read a route's synchronous exports from esbuild, not a regex

`export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all
reach the namespace without matching the declaration pattern the guard was
matching, so the lazy manifest dropped the boundary and the page painted blank.
A star re-export is now reported rather than read as clean.

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

* docs(mobile): say that the entry budget is not a per-route opt-out

Measured: statically importing one route already breaks the 3 MiB bound for 5 of
the 14. The hatch only works for a layout node, which is the only place
expo-router reads a synchronous export from.

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

* build(mobile): derive the chunk ceiling from the route count

64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's
routes would have failed on a number measured before they existed. Four per
route plus 16 tracks the measured slope; the entry-bytes bound stays the real
budget.

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

* refactor(mobile): drop the web entry's dead suspense boundary

expo-router wraps every screen in its own, so this one never fires; all nine
render checks stay green without it.

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

* test(mobile): pin that a client-side navigation fetches the next route's chunk

Goes red with splitting off: the tasks screen paints out of the entry and no new
script is fetched.

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

* fix(mobile): name every bundle output by its bytes, not by esbuild's path hash

esbuild's [hash] is over the metafile's input keys, which are paths relative to
absWorkingDir, so a checkout at another depth or with node_modules as a symlink
named a byte-identical chunk differently and shipped a different buildId for one
commit. Outputs are now renamed leaves-first to the sha256 of their final bytes.

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

* fix(mobile): fail the build on a route the lazy manifest would strip

The guard ran only in a test while the docstring said it failed the build. It
now runs in bundleMobileWebApp and names the route and the export.

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

* build(mobile): derive the asset ceiling from the chunk ceiling and the images

A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset
count would have failed first and named the count instead of the split. Chunks
plus images plus the document keeps the chunk ceiling the one that trips.

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

* test(mobile): split the route-manifest tests out of the bundle builder's

The builder's test file passed 600 lines. The route manifest, the synthesized
RequireContext and the web entry are their own subject and move together.

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

* fix(mobile): give the export guard the builder's route-source loaders

Without .js as jsx the guard reported a React Native .js route carrying JSX as
"JSX syntax extension is not enabled" instead of reading its exports.

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

* test(mobile): assert the navigation fetches the tasks route's own chunk

"some new script arrived" passed on any fetch. The builder now names the chunk
each route lands in, read off the metafile, and the check asserts that exact
path arrived and was not already loaded.

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

* fix(mobile): resolve a route's realpath before matching it to its chunk

esbuild writes metafile input keys after resolving symlinks, so every scratch
route tree under /var on macOS reached no output and failed the build.

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

* fix(mobile): fail the build when the asset ceiling outgrows the shell's map

The derived ceiling had no upper bound, and the native shells return null for a
manifest over their own 256 rather than truncating it. At 42 images the formula
crosses that at 50 routes, inside what Phase C adds, so the build would stay
green while the phone got nothing. The number is read from the contract through
esbuild, not restated here.

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

* test(mobile): cover the two hard stops in the content-addressed naming

Both throws only ran through a whole bundle before, where neither can be
provoked. A cycle and a route no output claims are now asserted directly; each
test goes red when its throw is removed.

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

* fix(mobile): exit the app-bundle build on one line, not a stack

The route-export guard fails this script by design, and a raw stack put the
route and the export name under twelve frames of node internals. Mirrors the
verifier's exit; the message is printed as thrown because every throw on this
path already names its source.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 11:58:42 -04:00
Jinwoo Hong ddbb194585 feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4) (#21467)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3)

One page document's end of the bridge: page frames in through the C0.1
reader, one RpcClient behind it, host frames out. Requests forward with the
arity the page used and answer with the verbatim RpcResponse, chunked when it
is over the frame cap; a rejection crosses as the five-field capture instead.
Subscriptions carry a seq and an unacked window, and end with `overflow`
rather than dropping frames a reader cannot see are missing.

The fence is structural: the protocol names no host, so the client is
whichever this host was built with, and the in-flight caps the page is told
about in `init` are enforced here rather than trusted from there.

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

* feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3)

The channel opens on the session B4 put on screen and closes with it. The
session id is B4's: nothing new is minted, and a remount is a new one, which
is what makes a dead page's frames fail the native origin check.

Both halves are stamped with the session they belong to, because React swaps
refs during the commit and runs the retiring effect's cleanup after it — a
host disposing on a remount would otherwise post its teardown into the page
that replaced it. `bridgeEnabled` is derived from the session step alone,
since the native side treats a prop change as a reload.

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

* test(mobile): prove the bridge fence holds for traffic, not just for answers

A mutation that dropped the post-teardown guard in `receive` survived: the
teardown case only fed a frame whose answer the outbound guard already
swallowed, so nothing observed that a dead page could still reach a live
client. Both teardown paths now feed a request, a subscribe and a notify,
and assert the client saw none of them.

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

* test(mobile): read the hook's frames through the page's own reader

`JSON.parse` returns `any`, and taming it with an assertion is a cast the
gate refuses and a check nobody gets. Reading each posted frame through
`readBridgeHostMessage` types it and proves the same thing the host's own
suite does: a frame the page would refuse is a frame that never arrives.

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

* chore(mobile): list the bridge host as a raw request port owner

The boundary ratchet reads a `.sendRequest` access as a call site, and the
host has three: one per arity the page can use. It is not a call site. It
picks no method, reads no reply and decides no acceptance — the page names
the method and runs the typed operation over the client this carries, which
is what the C0 design put page-side so `runRpcOperation` stays unchanged
there. That makes it an owner, beside the socket and relay senders, not a
migration backlog entry.

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

* feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4)

Every member of the native contract, carried over the C0.1 envelope so the
screens above it cannot tell a bridge from a socket: requests keep the arity the
caller used, a host RpcFailure resolves as data while a rejection is rebuilt with
its class and its delivery-unknown mark, subscriptions stream with periodic acks,
and the synchronous getters read a cache primed by init rather than answering
before they know.

A state whose generation went backwards is refused and re-asked for, because a
shell rebuilt under the page makes what the page holds the newer of the two.
close settles what the page owns and never touches the shell's client, which the
native screens and the host catalog still share.

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

* test(mobile): run the page client against the shell host over an in-memory port pair

One FIFO per direction and delivery on a microtask, which is what C0.5's golden
replay needs: a subscribe that overtook a sendRequest would move the recorder's
shared ordinal, and anything stronger than a microtask moves a virtual
millisecond. Every member round-trips through the real host over a fake client;
the frame-level suite covers what no pair can reach, including the handshake
backoff, refusals and the binary lane C6 will fill.

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

* chore(mobile): list the page bridge client as a raw request port owner

Both ends of the bridge hold the port as a transport: one forwards raw requests
and the other offers them, and neither picks a method or reads a reply.

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

* refactor(mobile): drop the assembler discard no abandoned request can reach

A request is only abandoned when its frame never left the page, so the shell was
never told the id and no part can have arrived under it. Says what actually keeps
an omitted param omitted while it is here: JSON drops an undefined value, so the
spread states the intent rather than producing the result.

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

* test(mobile): close the three gaps a mutation sweep found in the page client

A settled id has to give its assembler slot back, or 64 replies that were cut
short before an error leave the page unable to read the next chunked one. Close
says goodbye once rather than cancelling each stream first. And the read guard is
only observable through a port that ignores its own unsubscribe, which is what
the harness can now be.

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

* test(mobile): prove a stream that overflows inside subscribe is unsubscribed

A client that emits synchronously from `subscribe` can retire a stream before
its unsubscribe exists to be stored. The identity check that calls it instead
had no test; deleting it left the suite green while the client's stream leaked.

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

* fix(mobile): hand the bridge host over in the commit, not after it

A client swap that keeps the session id leaves the handler's own fence inert:
until the passive effect ran, a native frame reached the retiring host and the
client it closed over. A layout effect swaps both inside the commit.

Teardown on unmount now runs while the view is still attached, so a pending
request is answered delivery-unknown instead of being dropped on the floor.

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

* fix(mobile): hold a refused page frame to one warning per page

A page that sends one bad frame usually sends many, and a line each buries the
first — the one that says why. Same bound the host already keeps on a failing
post, applied per kind and reset when a new page gets a new host.

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

* fix(mobile): bound the page's terminal viewport at the bridge contract

A viewport crossing the bridge is written into the cached subscribe params of
every stream naming that terminal, including the native terminal screen's, and
the desktop refuses cols over 1000 or rows over 500 when those streams
resubscribe. Unbounded, one page could kill streams it never opened; the frame
is refused instead, and the bound is pinned to the desktop's own.

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

* fix(mobile): stop the page's close from latching the bridge host shut

One view carries every document the shell loads, so the page that says `close`
is not the last one. A latched host dropped the next document's `ready` in
silence, and a page that re-sends `ready` on a backoff would retry forever with
nothing posted and nothing logged. Close now cancels what the page owned and
leaves the host live; only dispose shuts it, and a frame arriving after that is
diagnosed rather than dropped.

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

* fix(mobile): keep a throwing client or post inside the bridge host

The `state` frame is sent from inside the client's own state-change fan-out and
a notify runs on the native event handler that delivered the page's frame, so a
synchronous throw from either escapes into a loop the bridge does not own and
takes unrelated listeners with it. Both are fenced and reported once.

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

* test(mobile): prove an ack releases the stream's unacked bytes

The frame window reopens on ack through the splice, so deleting the byte
release left every existing test green while a long-lived stream of large
frames would end with overflow on its first frame after an ack.

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

* fix(mobile): pass the commit-window harness its children as a prop

`createElement`'s variadic children do not satisfy a props type that declares
`children`, so the file dropped out of the tests typecheck ratchet.

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

* fix(mobile): render the harness from the commit-window wrapper, not as children

A props type that declares `children` is what `createElement`'s variadic form
does not satisfy, and passing it as a prop instead trips the react rule. The
wrapper renders the harness itself, which is the parent position the layout
effect ordering needs anyway.

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

* fix(mobile): pin the desktop viewport bound by reading it, not importing it

Mobile may not pull an rpc-contract *value* into its bundle, and the boundary
test that enforces that scans this test file too. The pin reads the schema's
own source instead, so drift in either bound still fails loudly.

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

* fix(mobile): settle a refused subscribe as the stream it was

The shell answers a refused `subscribe` with `error` on the stream's id.
Routing that to the pending requests dropped it, because no request is
open under that id: the page heard nothing and kept the slot forever.

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

* fix(mobile): report a reply or an error the page has no id for

Silently dropped before. Nothing recovers it in place, but a frame the
page cannot place means the two ledgers disagree, which is worth a line.

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

* fix(mobile): say disconnected on close instead of going silent

Every native client publishes the transition and keeps answering its last
snapshot; the screens read both. The page's client cleared the cache
instead, so a closing page left its listeners on a dot that never moved
and every getter throwing underneath it.

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

* fix(mobile): let a closed page client go inert, not throw

An unmounting screen still calls, and nothing on a teardown path catches.
Subscribe hands back a no-op dispose and the notifies do nothing, as the
native client's do, and a request rejects rather than throwing past the
caller's catch. A call before init still throws: that one is a bug.

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

* refactor(mobile): lift the init handshake out of the page client

The backoff that asks the shell for a session is its own concern, and the
client had grown past the file's line budget holding it.

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

* test(mobile): pin the cancel a page owes for a stream already ended

A screen unmounts on its own schedule, routinely after the shell gave up
on the stream. Only the double-dispose order was covered.

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

* docs(mobile): state what the page client does after close

The doc gave the pre-init rule and stopped; the after-close rule is the
opposite one, and subscription failures have no channel but a diagnostic.

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

* feat(mobile): read the shell's page channel as a client transport

The document-start installer leaves `postMessage` and one `onmessage`
slot, the intersection of what the two platforms inject. A page opened
outside the shell has no global at all, so reading it answers null rather
than throwing: the bundle still has to open in a browser.

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

* feat(mobile): give the page its bridge client instead of a placeholder

The web provider now builds BridgeRpcClient over the shell channel and
mounts nothing until `init` lands: every member throws before a session,
and a screen that rendered first would record its first frame against a
client that has none. Outside the shell there is no session coming, so
the placeholder stays and the route tree mounts at once, which is what
the Route A render check exercises.

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

* fix(mobile): declare the page provider test's probe instead of casting it

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

* fix(mobile): serve the bridge to one document at a time

A page's `close` now ends that document's turn: until the next `ready`
claims the view, every other frame is dropped and diagnosed instead of
reaching the client, and nothing is posted. Without the fence a straggler
from the closed document was still forwarded, and a `state` frame from the
still-running client landed in the replacement document before its `init`.

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

* fix(mobile): hold the request cap against the calls, not the page's ledger

`sendRequest` has no cancel, so a request the page cancelled or closed out
keeps running on the desktop until it answers. The cap now counts those
calls until each settles; counting the pending map let a page interleaving
`close` with batches hold many more than the cap `init` advertises.

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

* test(mobile): pin the ack ratio to the shell's window, not a copy of it

The ack interval test held 256 and 4 MiB as literals, so narrowing the
shell's window would have left the page acking too late with the test still
green. The comment naming the test that pins the ratio pointed at the wrong
file.

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

* fix(mobile): give back the slot of a subscribe that never left the page

A post that threw left the stream in the page's ledger with nothing open on
the shell's side, so 32 of them exhausted the subscription budget for the
life of the document. The slot goes back and the listener hears a terminal
error result, which is what the native client does with the same failure.

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

* fix(mobile): end a page stream through its listener, not only the log

A stream the shell ends or fails now reaches its listener as a terminal
error result, the way the native client's emitError does. A consumer reads
that result: host-worktree-refresh clears the flag that says the event
stream is live, and without it the worktree list stops updating for the life
of the document. A dispose the page asked for stays silent.

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

* fix(mobile): settle the old shell's work before adopting a new session

A second `init` naming a different sessionId is a rebuilt host with empty
tables: every pending request and every open stream the page still held
belonged to the shell that is gone. They now settle delivery-unknown and end
through their listeners before the new session is adopted. A second `init`
for the same session is what a re-asked `ready` earns, and keeps everything.

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

* fix(mobile): take the page's streams out of the ledger before failing them

A listener that resubscribes while the old shell's streams are being ended
is opening one against the shell that is arriving; draining the map first is
what keeps this loop from tearing that one down too. Fixes the lint the
previous commit left behind.

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

* docs(mobile): say how long a reply assembler's refusal actually lives

The tombstone is not kept forever: the request ledger discards the id as it
settles the caller, so it normally outlives only the rest of the reply that
raised it. The bounded map is there for the ids nothing settles.

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

* test(mobile): pin the ceiling the ready backoff stops widening at

An unclamped backoff reads the same for the first minute and then leaves a
page asking once an hour into a shell that is still booting behind it.

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

* docs(mobile): say why the document fence carries no epoch

Page frames reach the shell through one native listener per platform, so a
straggler from the closed document lands before the next document's `ready`
and the flag alone catches it. An echoed epoch would be a wire change for
nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 10:51:38 -04:00
Jinwoo Hong 381a3da46f feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449)
* refactor(mobile-web): share the bundle manifest assembly with a second builder

Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and
the helpers the Phase C app builder needs become exports. No behaviour change
to the shipped bootstrap bundle.

The CRLF guard grows two exemptions it needs once it is pointed at mobile/src:
the image and font extensions .gitattributes already pins -text, and the
gitignored webview engine modules the postinstall writes.

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

* feat(mobile): web entry for the host route tree, and its two transport siblings

The entry mounts app/h on react-native-web through expo-router's own ExpoRoot.
It lives inside mobile/ so one React resolves, and supplies RpcClientProvider
itself: the route tree starts below the native root layout that owns it.

route-manifest.ts is a real typed module whose body the builder replaces --
esbuild has no require.context. A virtual specifier would need an ambient
declaration and would leave the entry unchecked.

Two .web.* siblings, both listed with a reason in web-overrides.json: the
transport substitution point (a placeholder client until C0.4 lands
BridgeRpcClient) and the device token store, whose native path imports
expo-secure-store, which is {} on web.

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

* feat(build): build:mobile-web:app, the phone's host routes bundled for the web

Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app,
with the same manifest and the same two-scratch-build determinism check. Dark:
build:mobile-web, packaging and the A2 census are untouched, and C1 is what
flips build:release.

Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin
hashed assets rather than data: URLs, because the shell's CSP sets img-src
'self'; the render check under that exact header is what found it. The script is
referenced root-absolute for the same reason a <base> tag cannot be used: the
document is served at every route depth and base-uri is 'none'.

The budget sits below the contract's per-asset ceiling so growth trips a build
rather than a refused asset on a phone. esbuild splitting does not lower it:
one entry with only static imports emits one chunk (measured).

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

* fix(mobile): let React Native Web paint under the shell CSP

RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so
style-src 'self' blocks every rule and the page renders unstyled. Measured, not
predicted: the render check serves the document under this exact header and
reported the violation.

'unsafe-inline' is granted to style-src and nothing else. script-src 'self'
holds, which is the directive that decides whether page code can arrive any way
other than as a fetched same-origin script. The test now pins that scoping
rather than rejecting the token everywhere.

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

* ci: prove the Route A app bundle on every PR

A dedicated job, for the same reason the browser provider has one: it needs
mobile/node_modules and a real browser, and the sharded test matrix would pay
for both on every shard. It builds the bundle, verifies it, and runs the
builder, override-census and render suites. It ships nothing.

The mobile_web_app signal is lifted out of should_run the way static_analysis
is. A mobile-only diff is desktop-irrelevant and skips every gated job, and
that is exactly the diff that changes the page this job builds.

Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml
join the installer cache keys in the two workflows that build an installer off
a hashFiles key, since beforePack requires out/mobile-web and a mobile-only
change must miss those caches rather than reuse a stale build.

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

* test(build): pin the shipped builder against the app builder's own module name

The assertion named a specifier that no longer exists, so it held vacuously.

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

* test(mobile): assert the RN Web style-src grant in the Swift checks

The Swift twin of the Kotlin CSP test still required style-src 'self' and
no unsafe-inline anywhere, so it trapped on the approved grant.

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

* test(build): make the Route A render check name what each route paints

The check asserted only "some html, no errors", which expo-router's Unmatched
screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green.
Each route now asserts content only its own component produces, and the
unmatched case asserts the screen positively so the negatives discriminate.

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

* test(build): read the shell CSP past the comments that quote directives

Both constants document themselves with // comments containing quoted
directive text, which the quoted-string scan picked up as directives. One
parser now drops comment lines, and iOS and Android go through it.

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

* feat(build): honour a .web.* route sibling in the app bundle

Routes were imported by absolute path with the extension, so esbuild's
resolveExtensions never applied and a .web.tsx under app/ was dead code the
census still accepted. The manifest now carries a key and a module: the key
stays the native filename so the URL does not move, and the module is the web
sibling when one exists.

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

* test(build): tie each named shim to the esbuild option that implements it

The shim list was asserted against a literal copy of itself, which passes
however the build is configured. Each entry now carries an appliesTo that
reads its own option, checked against the real options object.

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

* chore(build): line up the CRLF exemptions, the budget comment, and the job scope

The builder loads .gif as a file but neither .gitattributes nor the CRLF scan
exempted it, so the blanket eol=lf pin would have rewritten one. A test now
keeps the two lists in step. The Phase C byte budget's comment sat on the
asset count, and a root package.json edit could change build:mobile-web:app
without running the job that proves it.

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

* style(build): satisfy the index-check lint rule in the CSP parser

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

* ci: key the installer caches on the mobile page trees too

beforePack builds the mobile web bundle into the installer. Today those bytes
are Phase A's, which src/** already covers, but once C1 flips the entry to
mobile/app a page-only change would hit a cache holding a stale installer.

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

* test(build): skip the bundling tests where mobile dependencies are absent

The sharded `test` job collects config/scripts/**/*.test.mjs and installs no
mobile dependencies, so the two new suites failed there on "Could not resolve
react-native-web". They now skip themselves with a message naming the job that
runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing
install fails it instead of skipping everything it exists to prove.

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

* fix(build): scan mobile/packages in the .web.* census

The census claimed the app entry never resolves into packages/, but the
dictation hook imports @orca/expo-two-way-audio and the built script carries
ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and
planting a .web.* in each scanned tree proves the scan is not passing because
a tree happens to be empty.

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

* test(build): assert the route exclusions against a tree that has them

mobile/app holds no test, spec or +api file, so the exclusion rule was
asserted against a tree it could not fire on. A scratch tree plants one of
each; dropping the rule now fails this test.

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

* test(build): 404 unknown file paths in the render check's page server

The server answered every path with the document, so pointing publicPath at
/wrong-prefix still rendered three green routes: the script is fetched from
the one prefix that is served. A path naming a file now has to come out of the
bundle, which is what the shell's manifest map does.

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

* test(build): cover the app bundle verifier's own checks

The verifier had no test. One doctors the buildId, which the packaged assert
catches; the other rewrites the tree so every digest still agrees and only the
two fresh builds can tell, which is what a stale out/ looks like. Deleting
either check now fails a test.

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

* chore(build): tidy the app bundle comments and the job's path prefixes

Drops an export nothing read, merges two comments that had drifted apart from
the constant they describe, and corrects the claim that the job runs on every
PR when it is path-gated. package.json leaves the prefix list because
GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it,
since the page resolves a .web.ts out of there.

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

* style(build): merge the duplicate node:fs/promises import in the census

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

* fix(mobile): redirect the hybrid shell route on the web page

app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls
requireNativeViewManager at import. In a browser that throws before React
mounts, and the route manifest imports every route statically, so one native
route left the whole page blank at every URL.

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

* test(build): fail the render check with the error that stopped the mount

The check waited on "#root has children" with Playwright's animation-frame
polling, so a route module that threw at import read as a bare 30s timeout
naming nothing. It now waits on a mount attribute the entry sets after the
router commits, polls on a timer, and races the wait against the first
uncaught error so the failure carries it.

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

* test(build): answer the favicon the render browser asks for

CI resolves the runner's Google Chrome, which requests /favicon.ico; the
bundled headless shell does not. The bundle carries no icon, so the server
answers 204 rather than turning a browser habit into a console error the
render assertions read as a page fault.

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

* test(build): settle the render check's uncaught-error race without rejecting

The entry throws during goto, before anything awaits the race, so a rejected
promise surfaced as an unhandled rejection beside the real failure. The same
signal now resolves with the error.

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

* chore(mobile): list the page transport in the raw request port inventory

The placeholder client implements the port, so the boundary test counts it as
an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient
replaces it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 09:50:37 -04:00