Commit Graph
3 Commits
Author SHA1 Message Date
Jinwoo HongandClaude f3bda1bf3e refactor(mobile): seam moves and the shared shell route guard for the source-control domain (OTA phase C, C4.1) (#21732)
* refactor(mobile): open PR sidebar URLs through the external-link seam (OTA phase C, C4.1)

The three openers in the PR sidebar called `Linking.openURL` directly:
a check's "open on the web", a comment's permalink, and a link inside
comment Markdown. Inside the shell's WebView react-native-web routes that
to `window.open(url, '_blank', 'noopener')`, which both shells refuse and
which resolves anyway, so the tap reports success and opens nothing. Both
C4 routes reach the sidebar, so both would have shipped that.

The census is the point rather than the three edits. It derives the two
future route closures through `mobileWebAppRouteClosure` and holds every
module in them to the seam, so a module entering either closure later is
ruled without anyone adding it here. Red first it named all three by
`path:line`: CommentMarkdown.tsx:2, PRChecksSection.tsx:2,
PRCommentCard.tsx:2, on both routes.

The walk it runs was the third copy of one function, so it moves into the
seam's own module beside the predicate that module exists to share, and
the files and tasks censuses now call it too. It reports `path:line` where
the copies reported paths; `reachesReactNativeLinking` keeps its name and
its meaning and is now derived from the line list, so there is one rule.
An empty offender list is empty in either shape, which is why repointing
the two landed censuses moves nothing they assert.

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

* refactor(mobile): copy through the platform clipboard seam in review and conflicts (OTA phase C, C4.1)

The two copy actions both C4 routes reach called `expo-clipboard`
directly: the conflict section's refresh commands and the review sheet's
notes. On the web that module is `navigator.clipboard`, which needs a
secure context — the iOS shell serves the page from a custom scheme and
Android from https, so the path works on one platform and silently not on
the other. `useClipboardWriter` is the seam C2.4 landed for exactly that.

Red first, the census named both routes: `ExpoClipboard.web.js` in each
closure, and `src/platform/clipboard.web.ts` in neither.

Both call sites also stopped ignoring whether the pasteboard took the
text. The conflict section already returned on a throw, so the seam's
rejection reaches an arm it had. `copyNotes` had none and its only caller
is `void controller.copyNotes()`, so a rejection would have been unhandled
with "Review notes copied" left on screen; it now catches and reports
through the screen's own error line. That is the one behaviour change here
and the reason `clipboard` joins its dependency array.

Its suite mocked `setStringAsync` as resolving `undefined`, which the seam
reads as a pasteboard that refused, so every copy would have gone down the
new refusal arm unseen. The mock now resolves `true` and two cases pin
both arms; mutating the catch away kills the refusal one.

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

* refactor(mobile): take the source-control router from the handoff seam (OTA phase C, C4.1)

The hub takes its router once, in the openers hook, and passes it down to
the runners and the panel — so one `useRouter()` is this domain's whole
reach into routing, and it was expo-router's own. Inside the shell's page
that posts no `navigate`, so the hub's push to review would stay in the
document whatever its grants, and its push to a native route would paint
Unmatched over the page. Inert today: no C4 route is registered yet.

`use-mobile-source-control-runners.ts` is the second case and the reason
the rule reads value imports rather than identifiers: it named expo-router
only to write `ReturnType<typeof useRouter>`, a value import in a type
position that keeps the module in the graph. `RouteHandoff` is the seam's
own name for that type.

The census is C3.1's, and its walk moves to `src/navigation` rather than
being copied a second time; each domain keeps only its own evidence, the
list of modules meant to hold a router. Red first it named both modules on
the expo-router rule and reported no handoff caller at all.

The C2.9 hop census is unchanged and cannot move: its targets come from
the call sites, and the derivation over this tree returns the same ten
targets and the same 26 unresolved sites before and after this commit,
byte for byte. Its `HANDED_OFF` pin is over registered routes, of which
this adds none.

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

* refactor(mobile): move the shell route guard out of the files domain (OTA phase C, C4.1)

`src/files/mobile-file-shell-route.ts` was never about files: it parses a
route against `BridgeInitRouteSchema` and builds the key a shell screen
remounts on. It moves to `src/mobile-web-shell/shell-screen-route.ts` as
`shellScreenRoute` / `shellScreenRouteKey`, with its test. The move is
pure — with the rename applied and comments stripped, the old file and the
new one diff to nothing.

Three routes had grown their own copy of the call and two had none. The
copies go: `agent-history` and `tasks` now ask the shared predicate, which
is the same schema and the same fallback they already had. `index.tsx` had
no guard at all, so a `.` or `..` host id was handed over and came back as
"Update Orca to open this workspace" painted over the native list behind
the switch; it now stays native. That is the one behaviour change here,
pinned red first and killed by mutation.

`web.tsx` keeps handing that route over on purpose and is exempt by name:
its fallback is a redirect to the route the user came from, so the host's
own verdict is the better answer there, which
`mobile-web-shell-route.test.tsx` already pins. No `key=` expression moved;
the three switches still key differently (host id, pathname, pathname plus
params) and making them agree is a behaviour change for another PR.

The census walks the route tree rather than a list, so a switch added later
is held to both rules without being added here.

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

* test(mobile): mount the copy cases without a client instead of casting one (OTA phase C, C4.1)

The changed-code gate flagged four type assertions on the two cases added
with the clipboard seam: they stubbed an `RpcClient` the way the file's
older cases do, and the gate reads changed lines. Copying reaches no
client at all, so they mount without one, which is both cast-free and a
truer statement of what the path needs.

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

* test(mobile): say what the censuses report and sort the red list by line (OTA phase C, C4.1 round 1)

Round-1 folds, four wordings and one ordering.

`externalLinkOffenders` said "every call site" and reports the line the
name enters the module: a named import once, however many times the module
calls `openURL`, because the import is what the rule is about and what has
to go. Only a namespace import reports its uses, there being no single
line to name. The docstring now says that.

Its red list sorted the rendered strings, which puts `:10` before `:2`.
It now sorts by path and then by line as a number. Pinned against a
written fixture rather than the tree, because the case needs a module with
sites either side of line ten and no module in a closure has to keep
having one — the first fixture used lines 11 and 12, where both orders
agree, and the mutation walked straight through it.

`shell-screen-route.test.ts` still named the files screens in its describe
after the guard stopped being theirs; it names what a switch does now.

`router-seam-census.test-support.ts` excluded `.test-support.ts` from the
walk, which the files census it was extracted from never did. Dropped, so
both censuses walk the same set. Inert today: neither `src/files` nor
`src/source-control` holds such a file, so it only decides the next one.

The `web.tsx` exemption claimed a redirect "that looks like nothing
happened". What was measured: adopting the guard there sends a `..` deep
link through `Redirect href="/h/.."` to the host route, which this PR
keeps native, so the developer lands on the host list with nothing said
about why the page did not open. The route is `__DEV__`-only and the
host's own failure screen is the better verdict.

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

* fix(mobile): see every react-native alias, normalise the host id, surface a refused copy (OTA phase C, C4.1 CodeRabbit)

Three bots findings on #21732, all real.

**Every alias, not the first.** `reactNativeLinkingSites` found namespace
imports with `exec` and inspected only the first binding, so a module
importing the namespace twice and calling `Linking.openURL` on the second
reported no site at all. It reads every alias now and counts a line once
however many meet on it. Red first with exactly that fixture.

**The host id can be an array.** `app/h/[hostId]/index.tsx` read it bare,
and Expo Router answers a repeated key with one: `String(['a','b'])` is
`a,b`, `encodeURIComponent` makes that the single segment `a%2Cb`, and the
segment rule accepts it — so the shell opened a page for a host nobody
has. Through `firstParam`, as the other four switches do. Red first it
handed over `/h/host-1%2Chost-2`, and the empty-array case found a second
one: `[]` is truthy, so a bare read built `/h/` and handed that over too;
`firstParam` answers `''` and the route stays native.

That import pulls the source-control screen state, and with it the lucide
barrel whose `LucideProvider` re-export is the gap the web build patches,
so the suite mocks the barrel as the other suites do. It moves no page
closure: the closure resolves `index.web.tsx`, which this does not touch,
and the index route still measures 3426 modules, 289 local, 22 families.

**A refused copy said nothing.** `PRConflictingFilesSection` caught the
rejection and returned: no tick, no message, a tap indistinguishable from
one that copied. The label now carries the third state, reusing the tasks
page's own wording for it, and the component has its first test. Mutating
the failure arm away reds it.

Its prop narrows to `Pick<PRInfo, 'mergeable' | 'conflictSummary'>`, which
is what it reads and what let the test drop a cast the gate flagged; every
caller holds a full `PRInfo` and satisfies it.

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

* test(mobile): read the censuses' subjects as code, not as text (OTA phase C, C4.1 round 2)

Round-2 additions. A separate commit because `d4b14d54e5` was already
made and this lane does not amend.

**The seam walk parses now.** Matching `X.Linking` in the text named it
inside a comment that talks about it and inside a string that quotes it,
and the named-import regex did the same for a commented-out import.
Checked against the previous implementation, all three fixtures were red
there: the comment case reported lines 2 and 3, the string case reported
the string's line beside the real call, and `// import { Linking } from
'react-native'` reported line 1. The walk builds a `SourceFile` and reads
import declarations and property accesses, so comments and strings are
gone by construction and the quote styles stop being a special case. Cost
measured on the three closure censuses: 3.3 s, unchanged.

**The route census reads the call, not the import.** A switch that keeps
the import while the call goes — deleted, or moved behind a branch that
never runs — looked exactly like one that asks. It now needs both, proved
by mutation: dropping `shellScreenRoute(` from `tasks.tsx` while leaving
its import names `tasks.tsx`. A fixture carries the same rule in
isolation, since every switch in the tree calls what it imports and the
case would otherwise be unfalsifiable against it.

**And recognises a switch by its import** of `MobileWebShellScreen` rather
than by `<MobileWebShellScreen` in the text, so an alias or a line break
the formatter chose cannot hide one and a comment cannot invent one.

The `app/h/[hostId]` root stays written out: deriving it from the manifest
is not a one-liner from here, the manifest being an `.mjs` this test reads
as text. What ties the two together instead is a new case asserting every
registered pathname starts with that prefix, so a page route outside it
fails rather than going unwalked.

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

* test(mobile): read the imported name, not the local one (OTA phase C, C4.1 CodeRabbit)

`import { Linking as NativeLinking } from 'react-native'` went through the
census untouched: the walk compared the specifier's local binding, which
is `NativeLinking`, while the imported name lives in `propertyName` when
a specifier renames it and only in `name` when it does not. Reproduced
before the fix — the aliased import with a call beside it reported no
site at all.

Reading `propertyName ?? name` closes it in both directions. A module
that renames `Linking` is named at its import line like any other, and a
module that imports `View as Linking` is no longer named for a local
binding that reaches nothing. The second was a false positive the old
comparison had by construction.

One more of the same class, found while checking and verified rather than
assumed: `import RN from 'react-native'` typechecks in this project (tsc
accepts it), and a default binding is the whole namespace exactly as
`* as RN` is, so `RN.Linking.openURL` through it was invisible too. The
default binding joins the alias set, which already reports uses rather
than the import.

Four fixtures. Three red on the previous walk: the renamed import, the
local-only `Linking`, and the default import. The fourth — an alias
imported that never reaches `Linking` — passed before and is here to hold
the other half of the rule, that importing react-native is not itself the
offence.

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

* test(mobile): parse each module as its own kind, and read re-exports (OTA phase C, C4.1 CodeRabbit)

Two ways a module reached `Linking` past the census, both reproduced
before the change.

Every file was parsed as TSX. In a `.ts` module `const id = <T>(value:
T) => value` is a generic arrow; as TSX it is an unclosed JSX element,
and the parser folds the rest of the file into the error node. A
`RN.Linking.openURL` after one reported nothing, and so did the same call
with its import above the arrow. The file name goes into the parse now
and TypeScript reads the kind off the extension; `externalLinkOffenders`
passes the real path, which it had all along.

`ExportDeclaration` was never inspected, so `export { Linking } from
'react-native'` put the name back in reach of anything importing that
module while the census saw an import list it was not on. All four shapes
are read — named, renamed, `export *` and `export * as` — and reported at
the export statement, which is the line to delete exactly as an import
is. A re-export of another name, or of `Linking` from somewhere that is
not react-native, stays unnamed.

Seven fixtures. Five red on the previous walk: the `.ts` generic arrow
and the four re-export shapes. The two that pass before and after hold
the other half, that re-exporting is not itself the offence.

The named-import and re-export clauses read `propertyName ?? name`
through one helper rather than two spellings of it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-19 21:06:05 -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