* feat(mobile): the page can claim the device Back key
The shell's page had no way to hear Android Back: every sheet inside it
early-returned on web, so the key popped the whole session route. Adds the
first negotiated shell-to-page frame kind alongside it.
- `back-claim`, page to shell, declared in `init.accepts`: the document is
holding the key, or has let it go.
- `back`, shell to page, declared in `ready.accepts`: one press, dispatched to
the newest consumer that takes it. A press nothing takes is handed back as a
`navigate-back` rather than dropped.
Both are optional fields on frames the other side already reads, so an old
shell never hears a claim and an old page is never sent a press; each pops as
it does today. No protocol bump and no stream opcode.
`bridge-host.ts` was at its line cap, so the notify forwarder moves to
`bridge-host-notify.ts` unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): Android Back closes the sheet on the page, not the screen
Inside the shell's page every sheet early-returned on web, so one press left
the session route with the sheet still open. The drawer, the right drawer and
the file-preview prompt now claim the key through one seam on both platforms:
`use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on
the shell's. All sixteen session sheets render through `MountedBottomDrawer`,
so the one claim there covers every one of them, and a census fails if a sheet
bypasses it.
`route-handoff.web.ts` claims while the page grew a stack of its own, and
hands the press back when it did not.
The shell takes the key off the navigator only while a claim is live: Android
gets a `hardwareBackPress` handler that returns the host's own answer, iOS
loses the stack's swipe-back. The claim is cleared on `document-started`, on a
remount, on a new `ready`, on anything that takes the generation off screen,
on the page's `close` and on dispose.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): a Back press closes a sheet on the real bundle
The unit suites reach both halves of the lane but never the two together on a
document a browser rendered. The render rig can now post a `back` frame, and
the drawer check opens the Filter sheet, reads the claim off the notify list,
sends one press and pins that the sheet closed with no `navigate-back` behind
it. Red without the drawer's claim: the claim never arrives.
Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is
shared with the native app and mounts under no page provider in a bare tree,
where `usePageBridgeClient` threw; the seam now reads the bridge through
`usePageBridgeClientIfPresent` and claims nothing without one.
Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route
handoff, `bridge-page-back.ts` through the envelope.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): mirror the Back seam's latest values from an effect
Three `ref.current = …` writes sat in render, which React replays and
discards. Each moves into a dependency-list-free effect declared ahead of the
registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already
uses for the same reason: the caller rebuilds the value every render, so there
is nothing to depend on, and `useRef` seeds the first mount. The registration
still keys on the claim alone, so a rebuilt handler re-registers nothing.
The web seam's test drops its two type assertions for a named fixture type.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): the page says its Back claim again on every init
The claim was edge-triggered and the shell forgets on purpose: it drops the
claim answering every `ready`, and a host rebuilt under a live page — a client
swap through forceReconnect, which leaves the WebView mounted — starts with
none at all. A document still holding a sheet was then unknown to the shell,
and the next press popped the screen out from under it.
`init` is the shell saying it is here now, so the page answers each one with
the state rather than with a transition. Posted after the session has taken
the frame, so the gate reads that `init`'s own `accepts` and a shell that
never named the claim still hears nothing.
Nothing is said while nothing is held. Every `init` answering a `ready` comes
from a host that dropped the claim first, so it already holds false; the only
other one carries a rewritten route, where a stale true needs a `false` the
page posted to have never left, and a port that refused that frame refuses
this one too.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): a rebuilt host keeps the session's Back claim
A host is rebuilt when the client under it changes, and the page document does
not move: the WebView stays mounted, the session id holds, and the page is
never told. The rebuilt host started with no claim and no `accepts`, so it
refused every press and the navigator popped the screen out from under an open
sheet. Two clients on the same generation leave the page nothing to refuse, so
nothing made it re-ask and re-assert.
What the page declared and what it is holding are facts about the session, the
way `sessionEstablished` already is. `createBridgeHostBack` takes them as a
seed, `readSessionBack()` hands them on, and the hook holds them stamped with
the session so a record left by one never seeds the next.
`dispose()` no longer reports the claim gone: a host retiring is not a
document ending, and that report was the thing taking the key off a live
sheet. Every reset path is unchanged and still has its own case — the page's
`ready`, its `close`, and the session's own store for `document-started`,
`remounted` and anything that takes the generation off screen.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb