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
This commit is contained in:
Jinwoo Hong
2026-09-19 15:47:25 -04:00
committed by GitHub
parent 76063e7ab1
commit ac024d4f05
28 changed files with 1631 additions and 64 deletions
+2
View File
@@ -716,6 +716,8 @@ jobs:
config/scripts/mobile-web-app-agent-history-render.test.mjs \
config/scripts/mobile-web-app-tasks-render.test.mjs \
config/scripts/mobile-web-app-tasks-external-links.test.mjs \
config/scripts/mobile-web-app-files-external-links.test.mjs \
config/scripts/mobile-web-app-files-render.test.mjs \
config/scripts/mobile-web-app-page-closure-families.test.mjs
cross-version-wire:
@@ -72,6 +72,26 @@ async function withScratch(run) {
}
}
/**
* Every page route this bundle declares, written out rather than read from the source that
* produces it: the point is to pin the list, and comparing the manifest to its own input would
* pass whatever that input became. Shared by the two assertions below, which is also what keeps
* this file under the 600-line cap.
*/
const EXPECTED_PAGE_ROUTES = [
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
{
pathname: '/h/[hostId]/tasks',
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
},
{ pathname: '/h/[hostId]/files/[worktreeId]', grants: ['navigate', 'storage', 'externalLink'] },
{
pathname: '/h/[hostId]/files/preview/[worktreeId]',
grants: ['navigate', 'storage', 'externalLink']
}
]
describe('the page routes the manifest declares', () => {
it('turns a route key into the URL pattern expo-router gives it', () => {
expect(routePathnameFromKey('./h/[hostId]/index.tsx')).toBe('/h/[hostId]')
@@ -88,14 +108,7 @@ describe('the page routes the manifest declares', () => {
it('declares only routes the bundle has a module for', async () => {
const keys = await collectMobileWebAppRouteKeys(appDir)
expect(resolveMobileWebPageRoutes(keys)).toEqual([
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
{
pathname: '/h/[hostId]/tasks',
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
}
])
expect(resolveMobileWebPageRoutes(keys)).toEqual(EXPECTED_PAGE_ROUTES)
})
it('fails the build on a declaration the bundle cannot render', () => {
@@ -114,14 +127,7 @@ describe('the page routes the manifest declares', () => {
async () => {
await withScratch(async (scratch) => {
const { manifest } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
expect(manifest.routes).toEqual([
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
{
pathname: '/h/[hostId]/tasks',
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
}
])
expect(manifest.routes).toEqual(EXPECTED_PAGE_ROUTES)
// The routes are derived from the same tree the script is built from, so the assets
// already decide them and the id has no reason to carry them as well.
expect(manifest.buildId).toBe(computeMobileWebBundleBuildId(manifest.assets))
@@ -393,6 +399,21 @@ describeBundling('the app bundle', () => {
expect(shipped).not.toContain('lucide')
})
it('ships no haptic that reaches for the DOM', async () => {
// expo-haptics' web build fakes an iOS haptic by appending a hidden
// `<label><input type="checkbox" switch>` to document.head, clicking it, and removing it —
// once per call. The file explorer calls triggerSelection on every row tap, and C1.9 already
// traced a swallowed long press on the worktree list to that stray click. `haptics.web.ts` is
// what keeps the whole shim out of the bundle, so this reads the bytes rather than the import.
for (const source of allScriptSource(await bundleMobileWebApp())) {
// The shim's own fingerprint, not `navigator.vibrate`: react-native-web's Vibration export
// calls that too, and it touches no DOM until something invokes it.
expect(source).not.toContain('ariaHidden')
expect(source).not.toContain('pointer: coarse')
expect(source).not.toContain('setAttribute("switch"')
}
}, 120_000)
it('embeds no absolute path from this checkout', async () => {
// Every chunk, not only the entry: the route manifest names each route by absolute path, and
// the chunk that import resolves to is where such a path would survive.
@@ -0,0 +1,26 @@
/**
* The external-link seam, and how a census recognises a module that went around it.
*
* Shared by every page route's census rather than restated in each: two spellings of one rule
* drift, and the half that stops being enforced is the half nobody reads again.
*/
/** The seam, as the web build resolves it: `.web.ts` wins under the builder's resolveExtensions,
* and it is the one module a page closure may reach react-native's `Linking` from. */
export const EXTERNAL_LINK_SEAM = 'src/platform/external-link.web.ts'
/**
* Whether a module reaches react-native's own `Linking`, by name or through a namespace import.
*
* Both quote styles: the tree is single-quoted by the formatter today, so a double-quoted
* specifier would have walked past this unseen — and a census that cannot see a call site is one
* that passes for the wrong reason.
*/
export function reachesReactNativeLinking(source) {
const named = /import\s*\{[^}]*\bLinking\b[^}]*\}\s*from\s*['"]react-native['"]/s
const namespace = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]react-native['"]/
const asNamespace = namespace.exec(source)
return (
named.test(source) || (asNamespace !== null && source.includes(`${asNamespace[1]}.Linking`))
)
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { reachesReactNativeLinking } from './mobile-web-app-external-link-seam.mjs'
describe('the seam predicate', () => {
it.each([
["import { Linking } from 'react-native'", true],
['import { Linking } from "react-native"', true],
["import * as RN from 'react-native'\nRN.Linking.openURL(u)", true],
['import * as RN from "react-native"\nRN.Linking.openURL(u)', true],
["import { View } from 'react-native'", false],
['import { View } from "react-native"', false]
])('reads %s as %s', (source, expected) => {
expect(reachesReactNativeLinking(source)).toBe(expected)
})
})
@@ -0,0 +1,127 @@
/**
* What the two files pages may reach for a URL and for the clipboard, which is what their grants
* are declared against.
*
* Inside the shell's WebView react-native-web's `Linking.openURL` calls `window.open(url, '_blank')`,
* which both shells refuse and which resolves whether or not anything opened, so a call site left on
* that path reports success into a tap that did nothing.
*
* Both routes declare `externalLink`, for different reasons, and this file holds each to its own.
* The preview renders Markdown and reaches the seam through `MobileMarkdown`, a consumer inside the
* domain. The explorer has no such consumer — its only reach is the shared host layout, which every
* page route reaches and which the worktree list declares nothing for — and declares the grant
* because its rows push to the preview in-page, under the session the explorer opened.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
EXTERNAL_LINK_SEAM as SEAM,
reachesReactNativeLinking
} from './mobile-web-app-external-link-seam.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const EXPLORER = 'app/h/[hostId]/files/[worktreeId].tsx'
const PREVIEW = 'app/h/[hostId]/files/preview/[worktreeId].tsx'
/** The seam's only in-domain consumer, and the reason the preview declares the grant itself. */
const MARKDOWN = 'src/components/MobileMarkdown.tsx'
function offenders(closure) {
return closure.local
.filter((file) => file !== SEAM)
.filter((file) => {
try {
return reachesReactNativeLinking(readFileSync(join(mobileDir, file), 'utf8'))
} catch {
return false
}
})
.sort()
}
describeClosure(
'the files page closures',
() => {
it.each([EXPLORER, PREVIEW])('opens every external URL through the seam: %s', async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(offenders(closure)).toEqual([])
})
it.each([EXPLORER, PREVIEW])(
'contains the seam, so the rule is not vacuous: %s',
async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(closure.local).toContain(SEAM)
expect(closure.local.length).toBeGreaterThan(250)
}
)
it('reaches the seam from the Markdown preview, which is what earns the preview its grant', async () => {
// The grant difference between the two routes, asserted rather than asserted-about: without
// this the preview's `externalLink` would be a line in a manifest nothing holds to a caller.
const preview = await mobileWebAppRouteClosure(PREVIEW)
const explorer = await mobileWebAppRouteClosure(EXPLORER)
expect(preview.local).toContain(MARKDOWN)
expect(explorer.local).not.toContain(MARKDOWN)
})
},
240_000
)
/**
* The explorer declares at least what the preview does, because it can become the preview.
*
* Grants are resolved once, from the route the shell opened: `grantsForRoute` reads
* `session.routePathname` and `init.grants.native` carries the answer for the life of that
* session. The explorer's rows push to the preview, and because the preview is a page route the
* handoff keeps that push inside the same document — no second `init`, no re-resolved grants. So a
* preview reached that way runs under the explorer's grants, and anything the preview is granted
* and the explorer is not is refused at the call site with nothing on screen to say so.
*
* Pinned as a superset rather than as equality: the explorer may legitimately need a grant the
* preview does not.
*/
describe('the grants an in-page hop carries', () => {
const grantsOf = (pathname) =>
MOBILE_WEB_PAGE_ROUTES.find((route) => route.pathname === pathname)?.grants
it('gives the explorer every grant the preview declares', () => {
const explorer = grantsOf('/h/[hostId]/files/[worktreeId]')
const preview = grantsOf('/h/[hostId]/files/preview/[worktreeId]')
// Both declared, so a renamed route cannot turn this into a comparison of two undefineds.
expect(explorer, 'the explorer is declared').toBeDefined()
expect(preview, 'the preview is declared').toBeDefined()
expect(preview.length, 'the preview declares something to inherit').toBeGreaterThan(0)
expect(preview.filter((grant) => !explorer.includes(grant))).toEqual([])
})
})
/**
* Neither files page writes a clipboard, which is why neither is granted `native.clipboard.write`.
*
* The control is the tasks closure: it does carry the seam, so a probe that finds nothing here is
* one that can find something when there is something to find.
*/
describeClosure(
'the clipboard the files pages reach',
() => {
it.each([EXPLORER, PREVIEW])('carries no clipboard at all: %s', async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(closure.modules.filter((file) => file.endsWith('ExpoClipboard.web.js'))).toEqual([])
expect(closure.local).not.toContain('src/platform/clipboard.web.ts')
})
it('finds the clipboard seam on the route that is granted it', async () => {
const tasks = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
expect(tasks.local).toContain('src/platform/clipboard.web.ts')
})
},
240_000
)
@@ -0,0 +1,178 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
/**
* The file preview's unsaved-draft prompt, in a real browser.
*
* The render check next door mounts both files routes and reads what they paint, but it never taps
* anything, so the one control on this screen that opens a modal was unproved on the page. Two
* things only a browser answers for it: that `ConfirmModal` — a `BottomDrawer`, and so Reanimated,
* a portal and a gesture handler — actually paints inside the shell's page, and that opening it
* registers no `hardwareBackPress` handler.
*
* The second is the whole reason this file exists. React Native Web's
* `BackHandler.addEventListener` is a `console.error` and an inert subscription, and the drawer
* registered one whenever it was visible and interactive. So the page's own hardware-back guard
* being platform-gated was never the end of it: any page drawer opening put that line on the
* console. Asserted as the absence of the line, with the modal's title as the precondition that
* something was actually opened.
*/
const HOST_ROUTE = '/h/render-check-host'
const WORKTREE = 'wt-1'
const PREVIEW_ROUTE = `${HOST_ROUTE}/files/preview/${WORKTREE}`
/** The patterns `init.pageRoutes` names, which is what the page matches a navigation against. */
const PAGE_ROUTE_PATTERNS = [
'/h/[hostId]',
'/h/[hostId]/files/[worktreeId]',
'/h/[hostId]/files/preview/[worktreeId]'
]
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
/** Exactly what the preview declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */
const PREVIEW_GRANTS = ['navigate', 'storage', 'externalLink']
/**
* A terminal artifact, which is the only preview this screen lets anyone edit.
*
* A worktree file is read-only, so it can hold no draft and never reaches the prompt.
* `isEditableMobileTerminalArtifactPreview` wants a ready, untruncated, non-image preview, and
* `hasUnsavedMobileTerminalArtifactDraft` wants the draft to differ from what was loaded.
*/
const ARTIFACT_PATH = '/logs/run.txt'
const ARTIFACT_TITLE = 'run.txt'
const LOADED_CONTENT = 'hello'
const EDITED_CONTENT = 'hello, edited'
const PREVIEW_PARAMS = {
source: 'terminalArtifact',
absolutePath: ARTIFACT_PATH,
grantId: 'grant-1'
}
const REPLIES = {
'files.readTerminalArtifact': {
content: LOADED_CONTENT,
truncated: false,
byteLength: LOADED_CONTENT.length
}
}
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-files-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
server = served.server
origin = served.origin
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 240_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
async function openPreview() {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: { pathname: PREVIEW_ROUTE, params: PREVIEW_PARAMS },
host: SHELL_HOST,
storage: {},
faultGrant,
grants: [faultGrant, ...PREVIEW_GRANTS],
pageRoutes: PAGE_ROUTE_PATTERNS,
replies: REPLIES
})
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
await page.goto(`${origin}/`, { waitUntil: 'load' })
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 60_000,
polling: 250
})
return { page, errors }
}
describeRender(
'the file preview page',
() => {
it('asks before discarding a draft, without registering a hardware back handler', async () => {
const { page, errors } = await openPreview()
// The editor is the proof the artifact loaded and the screen decided it was editable; the
// prompt is unreachable otherwise, so a missing one here would be a vacuous pass below.
const editor = page.getByLabel(`${ARTIFACT_TITLE} editor`)
await editor.waitFor({ timeout: 60_000 })
await editor.fill(EDITED_CONTENT)
await page.getByLabel('Back to files').click()
const title = page.getByText('Discard changes?')
await title.waitFor({ timeout: 30_000 })
expect(await title.isVisible()).toBe(true)
// The line react-native-web logs from `BackHandler.addEventListener`. The drawer registered
// one on every open before it was platform-gated, so this is red without that guard.
expect(errors.filter((entry) => entry.includes('BackHandler'))).toEqual([])
await page.close()
}, 180_000)
it('leaves the draft alone when the answer is to stay', async () => {
const { page } = await openPreview()
const editor = page.getByLabel(`${ARTIFACT_TITLE} editor`)
await editor.waitFor({ timeout: 60_000 })
await editor.fill(EDITED_CONTENT)
await page.getByLabel('Back to files').click()
await page.getByText('Discard changes?').waitFor({ timeout: 30_000 })
await page.getByText('Stay').click()
await page.getByText('Discard changes?').waitFor({ state: 'hidden', timeout: 30_000 })
// Still the page, still the draft: the prompt closing must not have navigated or reloaded.
expect(await editor.inputValue()).toBe(EDITED_CONTENT)
await page.close()
}, 180_000)
},
600_000
)
@@ -417,6 +417,43 @@ describeRender('the Route A page in a real browser', () => {
expect(text).not.toContain(UNMATCHED)
}, 60_000)
// Both files routes reach OrcaMobileWebShellView from their native file, whose module calls
// requireNativeViewManager at import and throws in a browser. The manifest defers every route
// behind `import()`, so that throw is invisible until the page opens this route — which is why
// it needs a `.web.tsx` sibling and why proving it costs a render of the route itself.
it('mounts the file explorer, which its native route module cannot do', async () => {
const worktreeRoute = `${HOST_ROUTE}/files/worktree-a`
const { errors, cspErrors, text } = await render(worktreeRoute, 'Files', {
shellRoute: { pathname: worktreeRoute, params: { name: 'Example Worktree' } }
})
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
expect(text).toContain('Files')
expect(text).toContain('Example Worktree')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('mounts the file preview, reading the file path out of a param and not a segment', async () => {
const previewRoute = `${HOST_ROUTE}/files/preview/worktree-a`
const { errors, cspErrors, text, url } = await render(previewRoute, 'readme.md', {
shellRoute: {
pathname: previewRoute,
params: { relativePath: 'docs/my notes/readme.md', source: 'worktree' }
}
})
expect(cspErrors).toEqual([])
// Empty, and that is the point: React Native Web's BackHandler logs "not supported on web" for
// anyone who registers one, so this line is what proves the screen no longer does. Android back
// inside the page therefore pops the native stack without the unsaved-draft prompt, which lives
// on the page's own Back control.
expect(errors).toEqual([])
// The title is the last segment of the path param, so this is also the proof the param
// survived the round trip through `URLSearchParams` that `/` and the space go through.
expect(text).toContain('readme.md')
expect(url).toBe(`${previewRoute}?relativePath=docs%2Fmy+notes%2Freadme.md&source=worktree`)
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('renders the unmatched route rather than crashing on a path with no module', async () => {
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`, UNMATCHED)
expect(cspErrors).toEqual([])
@@ -17,24 +17,14 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
EXTERNAL_LINK_SEAM as SEAM,
reachesReactNativeLinking
} from './mobile-web-app-external-link-seam.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
/** The seam, as the web build resolves it: `.web.ts` wins under the builder's resolveExtensions,
* and it is the one module in this closure allowed to reach react-native's `Linking`. */
const SEAM = 'src/platform/external-link.web.ts'
/** Whether a module reaches react-native's own `Linking`, by name or through a namespace import. */
function reachesReactNativeLinking(source) {
const named = /import\s*\{[^}]*\bLinking\b[^}]*\}\s*from\s*'react-native'/s
const namespace = /import\s*\*\s*as\s*(\w+)\s*from\s*'react-native'/
const asNamespace = namespace.exec(source)
return (
named.test(source) || (asNamespace !== null && source.includes(`${asNamespace[1]}.Linking`))
)
}
describeClosure(
'the tasks page closure',
() => {
+36
View File
@@ -28,5 +28,41 @@ export const MOBILE_WEB_PAGE_ROUTES = [
{
pathname: '/h/[hostId]/tasks',
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
},
// The file explorer. `navigate` because its Back pops the native stack. `storage` for the shared
// components the host layout renders above it.
//
// `externalLink` is transitive, not its own: a row opens the preview, and because that is a page
// route the handoff keeps the push inside this document. Grants are resolved once, from the route
// the shell opened (`grantsForRoute` on `session.routePathname`), so a preview reached that way
// runs under *this* route's grants for the life of the session — and a Markdown link in it would
// be refused by `notifyExternalLink` and do nothing at all. So a route must declare a superset of
// the grants of every page route its screens push to locally, which for this one means the
// preview's list. The census beside it pins that pair.
//
// Nothing the explorer itself renders opens a URL. The two openers in its own closure are the
// shared layout's — the protocol wall, and the New Workspace source field the sidebar renders on
// a wide layout — and every `/h` route reaches both, `/h/[hostId]` included, which declares no
// `externalLink`. That tablet tap stays dead on all of them: a pre-existing gap this route
// neither widens nor fixes.
//
// One hop is still open and is not this series' to close: the sidebar `HostScreen` the layout
// renders on a wide layout pushes to `/h/<id>/tasks` through the handoff, which is local, so
// from any page route on a tablet the tasks page runs without `native.clipboard.write` and its
// copy actions refuse silently. Pre-existing on main for the worktree list and agent history
// since C2.1; the fix is a handoff rule — hand off to the shell when the target's grants exceed
// the session's — in its own PR.
{
pathname: '/h/[hostId]/files/[worktreeId]',
grants: ['navigate', 'storage', 'externalLink']
},
// The file preview. Same three. `externalLink` is this route's own rather than inherited: a
// Markdown preview renders links and `MobileMarkdown` opens them through the platform seam, which
// is a consumer inside the domain rather than the shared wall. The explorer declares the same
// list only because it can become this route in-page, so the two happen to be equal today and
// the reasons are not.
{
pathname: '/h/[hostId]/files/preview/[worktreeId]',
grants: ['navigate', 'storage', 'externalLink']
}
]
+56 -5
View File
@@ -1,13 +1,64 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileFileExplorerPanel } from '../../../../src/files/MobileFileExplorerPanel'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
import {
mobileFileShellRoute,
mobileFileShellRouteKey
} from '../../../../src/files/mobile-file-shell-route'
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
import { useMobileWebShellEnabled } from '../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
/**
* The file explorer, from the desktop's bundle or from this app.
*
* The shell decides, not this switch: it renders the page only for a route the bundle lists with
* grants this app implements, and answers `native-route` otherwise, which is what `fallback` is.
* `enabled === null` is the flag read still settling and renders the native screen, which is the
* only frame a store build ever paints here.
*
* Encoded, not interpolated raw, for the reason `web.tsx` states: an id carrying `?`, `#` or
* whitespace would build a pathname the page refuses and mount nothing.
*/
export default function MobileFileExplorerScreen() {
const { hostId, worktreeId, name } = useLocalSearchParams<{
hostId: string
worktreeId: string
name?: string
// Through `firstParam`, as the tasks and agent-history switches do: expo-router answers a
// repeated query key with an array, and a bare read puts it straight into the template, where
// `String(['a','b'])` is `a,b` and `encodeURIComponent` makes it the single segment `a%2Cb` —
// which the bridge's segment rule accepts, so the shell opens a page for a host nobody has.
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
}>()
return (
const hostId = firstParam(params.hostId)
const worktreeId = firstParam(params.worktreeId)
const name = firstParam(params.name)
const enabled = useMobileWebShellEnabled()
const native = (
<MobileFileExplorerPanel hostId={hostId} worktreeId={worktreeId} name={name} embedded={false} />
)
const route =
hostId && worktreeId
? mobileFileShellRoute({
pathname: `/h/${encodeURIComponent(hostId)}/files/${encodeURIComponent(worktreeId)}`,
// Omitted rather than empty: the panel derives its own label from the worktree id when
// the caller named none, where `name=` with nothing after it is a label.
...(name === '' ? {} : { params: { name } })
})
: null
if (enabled !== true || !hostId || route === null) {
return native
}
// Keyed on the route: a host captures the grants its session was opened with, so a screen reused
// across a route change would keep authorising frames under the grants of the route the page has
// left. The key is what makes the change a remount, which disposes that bridge in the commit.
return (
<MobileWebShellScreen
key={mobileFileShellRouteKey(route)}
hostId={hostId}
route={route}
fallback={native}
/>
)
}
@@ -0,0 +1,29 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileFileExplorerPanel } from '../../../../src/files/MobileFileExplorerPanel'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
/**
* Web sibling for the file explorer.
*
* This page is what the shell renders for this route, so there is no shell to mount here and no
* flag to read: the switch already happened natively. Its native file also reaches
* OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a
* browser, which is what the page opening this route would hit.
*/
export default function MobileFileExplorerScreen() {
// Through `firstParam`, as the native sibling does. The page reaches this route only through
// `init.route`, whose params are a `Record<string, string>`, so an array cannot arrive today —
// read the same way regardless, because the two files are meant to be the same screen and a
// difference between them is a difference nobody would look for.
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
}>()
const hostId = firstParam(params.hostId)
const worktreeId = firstParam(params.worktreeId)
const name = firstParam(params.name)
return (
<MobileFileExplorerPanel hostId={hostId} worktreeId={worktreeId} name={name} embedded={false} />
)
}
@@ -1,7 +1,27 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileFilePreviewScreen } from '../../../../../src/files/MobileFilePreviewScreen'
import { normalizeMobileFilePreviewRouteParams } from '../../../../../src/files/mobile-file-preview-route'
import {
mobileFilePreviewShellParams,
normalizeMobileFilePreviewRouteParams
} from '../../../../../src/files/mobile-file-preview-route'
import {
mobileFileShellRoute,
mobileFileShellRouteKey
} from '../../../../../src/files/mobile-file-shell-route'
import { MobileWebShellScreen } from '../../../../../src/mobile-web-shell/MobileWebShellScreen'
import { useMobileWebShellEnabled } from '../../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
/**
* The file preview, from the desktop's bundle or from this app.
*
* Normalized before the switch, not after: a route the native screen would refuse is one the shell
* has no pathname to build from, and its own refusal message is a better screen than a page opened
* on params it will refuse again.
*
* Only the two path segments are spelled into the pathname; everything else — the file path among
* them — is a param, which is what keeps a `/`, a space or a `..` out of the segment vocabulary the
* bridge holds a route to.
*/
export default function MobileFilePreviewRoute() {
const params = useLocalSearchParams<{
hostId?: string | string[]
@@ -20,5 +40,33 @@ export default function MobileFilePreviewRoute() {
name?: string | string[]
worktreeName?: string | string[]
}>()
return <MobileFilePreviewScreen route={normalizeMobileFilePreviewRouteParams(params)} />
const route = normalizeMobileFilePreviewRouteParams(params)
const enabled = useMobileWebShellEnabled()
const native = <MobileFilePreviewScreen route={route} />
const shellRoute = route.ok
? mobileFileShellRoute({
pathname: `/h/${encodeURIComponent(route.params.hostId)}/files/preview/${encodeURIComponent(
route.params.worktreeId
)}`,
params: mobileFilePreviewShellParams(route.params)
})
: null
if (enabled !== true || !route.ok || shellRoute === null) {
return native
}
// Keyed on the whole route, params included, for two reasons. A host captures the grants its
// session was opened with, so only a remount drops the bridge the previous route opened. And the
// page learns its route exactly once, from `init`: a same-path param change — another file in
// this worktree — would otherwise leave the shell mounted and the page still showing the file it
// was opened on, with nothing to tell it otherwise.
return (
<MobileWebShellScreen
key={mobileFileShellRouteKey(shellRoute)}
hostId={route.params.hostId}
route={shellRoute}
fallback={native}
/>
)
}
@@ -0,0 +1,32 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileFilePreviewScreen } from '../../../../../src/files/MobileFilePreviewScreen'
import { normalizeMobileFilePreviewRouteParams } from '../../../../../src/files/mobile-file-preview-route'
/**
* Web sibling for the file preview.
*
* This page is what the shell renders for this route, so there is no shell to mount here and no
* flag to read: the switch already happened natively. Its native file also reaches
* OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a
* browser, which is what the page opening this route would hit.
*/
export default function MobileFilePreviewRoute() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
relativePath?: string | string[]
source?: string | string[]
absolutePath?: string | string[]
grantId?: string | string[]
terminal?: string | string[]
pathText?: string | string[]
cwd?: string | string[]
nativeChatTab?: string | string[]
nativeChatSession?: string | string[]
line?: string | string[]
column?: string | string[]
name?: string | string[]
worktreeName?: string | string[]
}>()
return <MobileFilePreviewScreen route={normalizeMobileFilePreviewRouteParams(params)} />
}
@@ -194,7 +194,11 @@ export function MountedBottomDrawer({
}, [onClose, progress])
useEffect(() => {
if (!visible || !interactive) {
// Native 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 inside the
// shell's page every drawer that opened put that line on the console and armed nothing. There
// is no hardware back to intercept in a WebView; the shell owns the one the phone has.
if (!visible || !interactive || Platform.OS === 'web') {
return
}
+2 -2
View File
@@ -8,8 +8,8 @@ import {
type ListRenderItem
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, X } from 'lucide-react-native'
import { useRouteHandoff } from '../navigation/route-handoff'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { getWorktreeLabel } from '../session/worktree-label'
import {
@@ -41,7 +41,7 @@ export function MobileFileExplorerPanel(props: {
onRequestClose?: () => void
}) {
const { hostId, worktreeId, name, embedded, onRequestClose } = props
const router = useRouter()
const router = useRouteHandoff()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const scopeRef = useRef('')
+20 -19
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert, BackHandler, Pressable, Text, View, useWindowDimensions } from 'react-native'
import { Pressable, Text, View, useWindowDimensions } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, Save } from 'lucide-react-native'
import { useRouteHandoff } from '../navigation/route-handoff'
import { getWorktreeLabel } from '../session/worktree-label'
import { colors, spacing } from '../theme/mobile-theme'
import { useForceReconnect, useHostClient } from '../transport/client-context'
@@ -13,6 +13,7 @@ import {
type MobileFilePreviewSource,
type MobileFilePreviewResult
} from './mobile-file-preview-request'
import { ConfirmModal } from '../components/ConfirmModal'
import { MobileFilePreviewBody } from './MobileFilePreviewBody'
import {
displayNameFromPreviewPath,
@@ -26,13 +27,14 @@ import {
shouldKeepDirtyDraftOnPreviewLoadResult
} from './mobile-file-preview-editability'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
import { useMobileFilePreviewBack } from './use-mobile-file-preview-back'
type Props = {
route: MobileFilePreviewRouteState
}
export function MobileFilePreviewScreen({ route }: Props) {
const router = useRouter()
const router = useRouteHandoff()
const previewParams = route.ok ? route.params : null
const { client, state: connState } = useHostClient(previewParams?.hostId)
const forceReconnect = useForceReconnect()
@@ -219,22 +221,11 @@ export function MobileFilePreviewScreen({ route }: Props) {
}
}, [canSaveArtifact, client, draftContent, previewSource, savedContent, saving])
const requestBack = useCallback(() => {
if (!hasUnsavedTerminalArtifactDraft) {
router.back()
return true
}
Alert.alert('Discard changes?', 'Unsaved edits will be lost.', [
{ text: 'Stay', style: 'cancel' },
{ text: 'Discard', style: 'destructive', onPress: () => router.back() }
])
return true
}, [hasUnsavedTerminalArtifactDraft, router])
useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', requestBack)
return () => subscription.remove()
}, [requestBack])
const leave = useCallback(() => router.back(), [router])
const { confirmingDiscard, requestBack, stay, discard } = useMobileFilePreviewBack({
hasUnsavedDraft: hasUnsavedTerminalArtifactDraft,
leave
})
return (
<View style={styles.container}>
@@ -284,6 +275,16 @@ export function MobileFilePreviewScreen({ route }: Props) {
}
onRetry={retry}
/>
<ConfirmModal
visible={confirmingDiscard}
title="Discard changes?"
message="Unsaved edits will be lost."
confirmLabel="Discard"
cancelLabel="Stay"
destructive
onConfirm={discard}
onCancel={stay}
/>
</View>
)
}
@@ -0,0 +1,78 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const FILES_ROOT = import.meta.dirname
/**
* Which modules here hold a router, so the census cannot pass by seeing nothing.
*
* Inside the shell's 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. A
* screen holding expo-router's own `useRouter` posts no `navigate`, so a target outside the page
* paints Unmatched over it and a target inside it still works — which is why this is a census and
* not a behaviour test: the failure is invisible from either screen's own tests.
*/
const ROUTER_HOLDERS = ['MobileFilePreviewScreen.tsx', 'MobileFileExplorerPanel.tsx']
function productFiles(): string[] {
return readdirSync(FILES_ROOT, { recursive: true, encoding: 'utf8' })
.map((entry) => entry.replaceAll('\\', '/'))
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry))
}
function parse(name: string): ts.SourceFile {
return ts.createSourceFile(
name,
readFileSync(join(FILES_ROOT, name), 'utf8'),
ts.ScriptTarget.Latest,
true,
name.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
}
/** Value imports only: a `import type { Href } from 'expo-router'` names no runtime router. */
function importsExpoRouterValue(source: ts.SourceFile): boolean {
return source.statements.some((statement) => {
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly === true) {
return false
}
const specifier = statement.moduleSpecifier
return ts.isStringLiteral(specifier) && specifier.text === 'expo-router'
})
}
function callsRouteHandoff(source: ts.SourceFile): boolean {
let found = false
const visit = (node: ts.Node): void => {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'useRouteHandoff'
) {
found = true
}
ts.forEachChild(node, visit)
}
ts.forEachChild(source, visit)
return found
}
describe('the files domain reaches the router through the handoff seam', () => {
const files = productFiles()
it('walks the modules it is written against', () => {
expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS))
})
it('imports no router from expo-router, which the page cannot hand a route back through', () => {
expect(files.filter((name) => importsExpoRouterValue(parse(name)))).toEqual([])
})
it('takes the router from useRouteHandoff at every screen that holds one', () => {
expect(files.filter((name) => callsRouteHandoff(parse(name))).sort()).toEqual(
[...ROUTER_HOLDERS].sort()
)
})
})
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import {
BRIDGE_ROUTE_HREF_PATTERN,
BRIDGE_ROUTE_PATHNAME_PATTERN
} from '../mobile-web-shell/bridge/bridge-caps'
import { shellRouteHref } from '../mobile-web-shell/bridge/page-bootstrap'
import { stringifyRouteHref } from '../navigation/route-href'
import { createMobileFilePreviewHref } from './mobile-file-preview-route'
import { mobileFileShellRoute } from './mobile-file-shell-route'
/**
* Every shape of a real file path that the bridge's route vocabulary would refuse as a segment.
*
* None of them is refused, and that is the design: the pathname spells only `hostId` and
* `worktreeId`, and the path itself is a param. This is the test that says so for each one rather
* than for a representative.
*
* What the percent-encoding is load-bearing for is narrower than "every path", and the two lists
* below are that split rather than a claim over the whole set. Two different rules decide it, and
* neither is about paths:
*
* - the query half of `BRIDGE_ROUTE_HREF_PATTERN` is `[^#\s]*`, so it refuses whitespace of any
* kind and a `#`, and admits everything else verbatim;
* - the reader is `URLSearchParams`, which is form-urlencoded: it takes `&` as the end of the
* pair, `+` as a space, and `%XX` as an escape.
*
* So a value needs the encoder exactly when it carries whitespace, `#`, `&`, `+`, or a `%` that
* begins a valid escape. A `/`, a dot segment, an `=` after the first one, a lone `%` and non-ASCII
* all pass both rules untouched. That is why hand-joining the query in `stringifyRouteHref` reds
* some of these paths and not others, and why this file pins the two groups by behaviour instead
* of asserting one rule over a list of paths.
*/
const ENCODING_LOAD_BEARING = [
'src/my file.ts',
'a%2Fb.ts',
'a#b.ts',
// `+` decodes to a space and `&` ends the pair, so both come back as a different path entirely.
'a+b.ts',
'a&b.ts',
// Whitespace is whitespace to the pattern, so this one is refused rather than altered.
'a\nb.ts'
]
const ENCODING_NEUTRAL = [
'docs/readme.md',
'../etc/passwd',
'docs/日本語.md',
'/logs/run.txt',
// Only the first `=` splits the pair, and a `%` that begins no valid escape is left alone.
'a=b.ts',
'a%b.ts'
]
const HAZARD_PATHS = [...ENCODING_LOAD_BEARING, ...ENCODING_NEUTRAL]
/** The path as the other side reads it back out of the query it arrived in. */
function relativePathFromHref(href: string): string | null {
const query = href.slice(href.indexOf('?') + 1)
return new URLSearchParams(query).get('relativePath')
}
describe.each(HAZARD_PATHS)('a file path the route carries: %s', (relativePath) => {
it('is a route the page can be given, and a pathname with no path in it', () => {
const route = mobileFileShellRoute({
pathname: '/h/host-1/files/preview/wt-1',
params: { relativePath, source: 'worktree' }
})
expect(route).not.toBeNull()
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(route?.pathname ?? '')).toBe(true)
})
it('survives the href the shell writes into the page history', () => {
const href = shellRouteHref({
pathname: '/h/host-1/files/preview/wt-1',
params: { relativePath, source: 'worktree' }
})
expect(BRIDGE_ROUTE_HREF_PATTERN.test(href)).toBe(true)
expect(relativePathFromHref(href)).toBe(relativePath)
})
it('survives the href the page would hand back to the shell', () => {
const href = stringifyRouteHref(
createMobileFilePreviewHref({
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
source: 'worktree'
})
)
expect(BRIDGE_ROUTE_HREF_PATTERN.test(href)).toBe(true)
expect(relativePathFromHref(href)).toBe(relativePath)
})
})
/**
* Which of the seven the encoder is the only thing standing between and a refused or altered href.
*
* Asserted by building the href the unencoded way and reading what happens to it, so the split is
* pinned by behaviour rather than by a comment: move a path between the two lists and this fails.
*/
describe('what the percent-encoding is load-bearing for', () => {
const rawHref = (relativePath: string) =>
`/h/host-1/files/preview/wt-1?relativePath=${relativePath}&source=worktree`
it.each(ENCODING_NEUTRAL)('survives the query unencoded: %s', (relativePath) => {
const href = rawHref(relativePath)
expect(BRIDGE_ROUTE_HREF_PATTERN.test(href)).toBe(true)
expect(relativePathFromHref(href)).toBe(relativePath)
})
it.each(ENCODING_LOAD_BEARING)('does not survive the query unencoded: %s', (relativePath) => {
const href = rawHref(relativePath)
const refused = !BRIDGE_ROUTE_HREF_PATTERN.test(href)
const altered = relativePathFromHref(href) !== relativePath
expect(refused || altered).toBe(true)
})
})
describe('what the route vocabulary does refuse', () => {
it('refuses the same path spelled as a segment, which is why it never is one', () => {
// The counterfactual the cases above depend on: if the segment rule admitted these, the
// encoding would not be what is keeping them safe and this file would prove nothing.
for (const relativePath of ['../etc/passwd', 'src/my file.ts', 'a#b.ts']) {
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(`/h/host-1/files/preview/${relativePath}`)).toBe(
false
)
}
})
it('refuses a fragment even in the query half, so a path carrying one has to be encoded', () => {
expect(BRIDGE_ROUTE_HREF_PATTERN.test('/h/host-1/files/preview/wt-1?relativePath=a#b.ts')).toBe(
false
)
})
})
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
createMobileFilePreviewHref,
displayNameFromPreviewPath,
mobileFilePreviewShellParams,
normalizeMobileFilePreviewRouteParams
} from './mobile-file-preview-route'
@@ -132,6 +133,57 @@ describe('mobile-file-preview-route', () => {
})
})
it('hands the shell every param but the two the pathname spells as segments', () => {
expect(
mobileFilePreviewShellParams({
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath: 'docs/my notes/readme.md',
source: 'worktree',
line: '12'
})
).toEqual({ relativePath: 'docs/my notes/readme.md', source: 'worktree', line: '12' })
})
it('leaves an absent param out rather than sending it empty', () => {
// Driven through the normalizer because that is the only producer: it sets every optional key,
// so `line` is present with an `undefined` value rather than missing, and a literal written by
// hand here would omit the key and test nothing. The page reads these back through
// useLocalSearchParams, where a key present and empty is a different answer from one that was
// never there: `line: ''` scrolls nowhere, `line` absent opens the file at the top.
const route = normalizeMobileFilePreviewRouteParams({
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath: 'readme.md'
})
if (!route.ok) {
throw new Error(route.message)
}
expect('line' in route.params).toBe(true)
expect(mobileFilePreviewShellParams(route.params)).toEqual({
relativePath: 'readme.md',
source: 'worktree'
})
})
it('carries a terminal artifact by its absolute path, which is not a segment either', () => {
expect(
mobileFilePreviewShellParams({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'terminalArtifact',
absolutePath: '/logs/run 1.txt',
grantId: 'grant-1',
cwd: '/logs'
})
).toEqual({
source: 'terminalArtifact',
absolutePath: '/logs/run 1.txt',
grantId: 'grant-1',
cwd: '/logs'
})
})
it('derives display names from slash or backslash paths only for display', () => {
expect(displayNameFromPreviewPath('src/app.ts')).toBe('app.ts')
expect(displayNameFromPreviewPath('src\\app.ts')).toBe('app.ts')
@@ -107,6 +107,21 @@ export function normalizeMobileFilePreviewRouteParams(
}
}
/**
* The params the shell hands the page, which are this route's own minus the two it spells as path
* segments. Every value is a `string`, because that is what `BridgeInitRoute.params` carries and
* what `URLSearchParams` will encode it back out of; an absent one is left out rather than sent
* empty, so the page's `useLocalSearchParams` reads exactly what the native screen read.
*/
export function mobileFilePreviewShellParams(
params: MobileFilePreviewRouteParams
): Record<string, string> {
const { hostId: _hostId, worktreeId: _worktreeId, ...rest } = params
return Object.fromEntries(
Object.entries(rest).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]]))
)
}
export function createMobileFilePreviewHref(
params: MobileFilePreviewRouteParams
): MobileFilePreviewHref {
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { BRIDGE_MAX_ROUTE_PARAM_CHARS } from '../mobile-web-shell/bridge/bridge-caps'
import {
BridgeInitRouteSchema,
type BridgeInitRoute
} from '../mobile-web-shell/bridge/bridge-envelope'
import { shellRouteHref } from '../mobile-web-shell/bridge/page-bootstrap'
import { mobileFileShellRoute, mobileFileShellRouteKey } from './mobile-file-shell-route'
import {
mobileFilePreviewShellParams,
normalizeMobileFilePreviewRouteParams
} from './mobile-file-preview-route'
const PREVIEW_PATH = '/h/host-1/files/preview/wt-1'
function previewRoute(absolutePath: string) {
const route = normalizeMobileFilePreviewRouteParams({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'terminalArtifact',
absolutePath,
grantId: 'grant-1'
})
if (!route.ok) {
throw new Error(route.message)
}
return { pathname: PREVIEW_PATH, params: mobileFilePreviewShellParams(route.params) }
}
describe('the route the files screens hand the shell', () => {
it('is one the page could actually be given', () => {
const route = previewRoute('/logs/run.txt')
expect(BridgeInitRouteSchema.safeParse(route).success).toBe(true)
expect(mobileFileShellRoute(route)).toEqual(route)
})
it('is nothing when a file path is longer than a param may be', () => {
// Not hypothetical: this is the shape a Windows long path arrives in, and the first assertion
// is what says the schema really refuses it. Without the guard the screen hands it over,
// bridge-host drops the route to null, and the page paints "Update Orca to open this
// workspace" over a native screen that works.
const route = previewRoute(`/logs/${'a'.repeat(BRIDGE_MAX_ROUTE_PARAM_CHARS)}.txt`)
expect(BridgeInitRouteSchema.safeParse(route).success).toBe(false)
expect(mobileFileShellRoute(route)).toBeNull()
})
it('is nothing when a worktree id is not a segment the page will route', () => {
// The C1.8 class: `..` survives encodeURIComponent, and the page resolves a dot segment out of
// the `/h/` prefix it is supposed to stay inside.
const route = { pathname: '/h/host-1/files/..', params: { name: 'Files' } }
// The schema first, as the length case does: without it a `null` here would also be what a
// guard that refused everything produces.
expect(BridgeInitRouteSchema.safeParse(route).success).toBe(false)
expect(mobileFileShellRoute(route)).toBeNull()
})
it('keeps a path with a slash, a space and a dot segment, which are params and not segments', () => {
const route = {
pathname: '/h/host-1/files/preview/wt-1',
params: { relativePath: 'docs/../my notes/readme.md', source: 'worktree' }
}
expect(mobileFileShellRoute(route)).toEqual(route)
})
})
describe('the key a shell screen remounts on', () => {
/**
* The key has to be the URL the page would end up at, because that is what it would be showing.
* `shellRouteHref` is the page's own serializer and cannot be imported into a native route file —
* it lives beside the page's RPC client — so the copy is pinned equal here rather than trusted.
*/
it.each<BridgeInitRoute>([
{ pathname: '/h/host-1/files/wt-1' },
{ pathname: '/h/host-1/files/wt-1', params: { name: 'my worktree' } },
{
pathname: '/h/host-1/files/preview/wt-1',
params: { relativePath: 'docs/my notes/readme.md', source: 'worktree', line: '12' }
}
])('is the href the page would write into its history: %o', (route) => {
expect(mobileFileShellRouteKey(route)).toBe(shellRouteHref(route))
})
})
@@ -0,0 +1,47 @@
import {
BridgeInitRouteSchema,
type BridgeInitRoute
} from '../mobile-web-shell/bridge/bridge-envelope'
/**
* The route to hand the shell, or nothing if the page could not be given it.
*
* `bridge-host.ts` parses the route against this same schema and drops it to `null` when it fails,
* so a route that does not fit reaches the phone as an `init` naming no screen — and the page
* answers that with "Update Orca to open this workspace", which is both wrong and worse than the
* native screen sitting right behind the switch. Deciding here instead means the route stays
* native, which is where every route starts.
*
* A file path is the reason this domain needs it. Paths are params, not segments, so `/`, spaces
* and `..` are all fine; length is not bounded by anything the user cannot exceed, and
* `BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 while a Windows long path is not. The same call also
* catches a `worktreeId` the segment rule refuses, which is the C1.8 class.
*
* The schema itself is the predicate rather than a copy of its bounds: two spellings of one rule
* drift, and the half that matters is the half the page reads. This belongs in the shell beside
* that schema; it lives here while the contract files are the C2 lane's.
*/
export function mobileFileShellRoute(route: BridgeInitRoute): BridgeInitRoute | null {
return BridgeInitRouteSchema.safeParse(route).success ? route : null
}
/**
* The identity of a route as the page will experience it, which is what a shell screen keys on.
*
* The pathname is not enough. The page learns its route exactly once, from `init`, and writes it
* into its own history before the first render; nothing later tells it the route moved. So a
* same-path param change — another file in the same worktree, a different `name` — leaves the
* shell and its bridge host mounted, the page still showing the file it was opened on, and the
* host answering any later `init` with whatever route it now holds. Keying on the params as well
* makes that change a remount, which is the only thing that hands the page a new route.
*
* Serialized the same way the page's own bootstrap serializes it, so two routes that would put the
* same URL in the page's history are the same key. Deliberately not imported from there:
* `shellRouteHref` lives in `page-bootstrap.ts`, which reaches the page's RPC client and its
* document channel, and a native route file must not pull those into the app. The test pins the
* two equal instead, which is the dependency this comment actually has.
*/
export function mobileFileShellRouteKey(route: BridgeInitRoute): string {
const search = new URLSearchParams(route.params ?? {}).toString()
return search === '' ? route.pathname : `${route.pathname}?${search}`
}
@@ -0,0 +1,154 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => {
// Annotated rather than asserted: the literal alone narrows to 'ios' and the tests reassign it.
const platform: { os: 'ios' | 'android' | 'web' } = { os: 'ios' }
return {
platform,
addEventListener: vi.fn((_event: string, _handler: () => boolean) => ({ remove: vi.fn() }))
}
})
vi.mock('react-native', () => ({
BackHandler: {
addEventListener: (event: string, handler: () => boolean) =>
native.addEventListener(event, handler)
},
get Platform() {
return { OS: native.platform.os }
}
}))
import {
useMobileFilePreviewBack,
type MobileFilePreviewBack
} from './use-mobile-file-preview-back'
const held: { back: MobileFilePreviewBack | null } = { back: null }
function Screen({ hasUnsavedDraft, leave }: { hasUnsavedDraft: boolean; leave: () => void }): null {
held.back = useMobileFilePreviewBack({ hasUnsavedDraft, leave })
return null
}
function render(hasUnsavedDraft: boolean, leave: () => void): ReactTestRenderer {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(createElement(Screen, { hasUnsavedDraft, leave }))
})
if (!renderer) {
throw new Error('the probe did not render')
}
return renderer
}
beforeEach(() => {
native.platform.os = 'ios'
native.addEventListener.mockClear()
held.back = null
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('leaving the file preview', () => {
it('leaves straight away when there is nothing to lose', () => {
const leave = vi.fn()
render(false, leave)
act(() => {
expect(held.back?.requestBack()).toBe(true)
})
expect(leave).toHaveBeenCalledTimes(1)
expect(held.back?.confirmingDiscard).toBe(false)
})
it('asks before dropping an unsaved draft, and does not leave while it is asking', () => {
const leave = vi.fn()
render(true, leave)
act(() => {
expect(held.back?.requestBack()).toBe(true)
})
expect(leave).not.toHaveBeenCalled()
expect(held.back?.confirmingDiscard).toBe(true)
})
it('stays on the answer to stay', () => {
const leave = vi.fn()
render(true, leave)
act(() => {
held.back?.requestBack()
})
act(() => {
held.back?.stay()
})
expect(held.back?.confirmingDiscard).toBe(false)
expect(leave).not.toHaveBeenCalled()
})
it('leaves once, on the answer to discard', () => {
const leave = vi.fn()
render(true, leave)
act(() => {
held.back?.requestBack()
})
act(() => {
held.back?.discard()
})
expect(held.back?.confirmingDiscard).toBe(false)
expect(leave).toHaveBeenCalledTimes(1)
})
it('drops the question when the draft it was about is gone', () => {
// A save lands while the prompt is up: there is nothing left to discard, and a prompt still
// offering to throw work away would be offering to throw away nothing.
const leave = vi.fn()
const renderer = render(true, leave)
act(() => {
held.back?.requestBack()
})
expect(held.back?.confirmingDiscard).toBe(true)
act(() => {
renderer.update(createElement(Screen, { hasUnsavedDraft: false, leave }))
})
expect(held.back?.confirmingDiscard).toBe(false)
expect(leave).not.toHaveBeenCalled()
})
it('does not re-show itself on the next edit after a save', () => {
// `asking` outlives the draft it was about unless something clears it: with the prompt up, a
// save makes `hasUnsavedDraft` false and the prompt hides, but the flag is still set, so the
// very next edit puts the prompt back with no Back request behind it.
const leave = vi.fn()
const renderer = render(true, leave)
act(() => {
held.back?.requestBack()
})
expect(held.back?.confirmingDiscard).toBe(true)
// The save lands: nothing left to discard.
act(() => {
renderer.update(createElement(Screen, { hasUnsavedDraft: false, leave }))
})
expect(held.back?.confirmingDiscard).toBe(false)
// The next edit. Nobody asked to leave, so nobody should be asked about it.
act(() => {
renderer.update(createElement(Screen, { hasUnsavedDraft: true, leave }))
})
expect(held.back?.confirmingDiscard).toBe(false)
expect(leave).not.toHaveBeenCalled()
})
it('arms the hardware back press natively', () => {
render(false, vi.fn())
expect(native.addEventListener).toHaveBeenCalledTimes(1)
expect(native.addEventListener.mock.calls[0]?.[0]).toBe('hardwareBackPress')
})
it('never arms it on the web, where it is inert and says so on the console', () => {
native.platform.os = 'web'
render(false, vi.fn())
expect(native.addEventListener).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,87 @@
import { useCallback, useEffect, useState } from 'react'
import { BackHandler, Platform } from 'react-native'
export type MobileFilePreviewBack = {
/** Whether the screen is asking about an unsaved draft instead of leaving. */
confirmingDiscard: boolean
/** Returns true when it handled the request, which is what a hardware back press reads. */
requestBack: () => boolean
stay: () => void
discard: () => void
}
/**
* Leaving the preview, and the one question that can stop it.
*
* The prompt is a `ConfirmModal` rather than `Alert.alert`, because React Native Web's `Alert` is
* `static alert() {}` — a silent no-op. Inside the shell's page that turned Back with an unsaved
* draft into a button that did nothing at all: no prompt, and no navigation either.
*
* `ConfirmModal` is what every other confirm in this app uses, and it keeps the modal semantics the
* native screen had before the page existed. It is a `BottomDrawer`, which C3 first wrote around
* because C1.9 had Reanimated's animated styles never reaching the DOM node on WKWebView; C1.10
* (`b7c06900e2`) fixed that by giving the mapper hooks a dependency array, and the drawer render
* check now holds it on WebKit as well as Chromium.
*
* 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 returns an inert
* subscription, so on web this guard never armed regardless, and skipping it states the
* degradation instead of hiding it. Android back inside the page therefore pops the native stack
* without this prompt — the page's own Back control is where the prompt lives.
*
* Skipping it here does not keep that line off the console on its own: `mounted-bottom-drawer.tsx`
* registered one of its own whenever a drawer was visible and interactive, so the prompt opening
* put it there anyway. That registration is platform-gated now too, at the drawer, which is where
* it belongs; the files render check holds both by asserting the line's absence after the prompt
* is open.
*/
export function useMobileFilePreviewBack(options: {
hasUnsavedDraft: boolean
leave: () => void
}): MobileFilePreviewBack {
const { hasUnsavedDraft, leave } = options
const [asking, setAsking] = useState(false)
const [askedAbout, setAskedAbout] = useState(hasUnsavedDraft)
// Adjusted during render, not in an effect: an effect that answered this would paint one frame
// still asking, which is the shape React Doctor names.
//
// The request belongs to the draft it was made about. `asking && hasUnsavedDraft` hides the
// prompt when a save or a revert empties the draft, but on its own it leaves the flag set, so
// the next edit put the prompt back with no Back request behind it. Dropping the request when
// the draft goes is what ends it with the thing it was about.
if (askedAbout !== hasUnsavedDraft) {
setAskedAbout(hasUnsavedDraft)
if (!hasUnsavedDraft) {
setAsking(false)
}
}
const confirmingDiscard = asking && hasUnsavedDraft
const requestBack = useCallback(() => {
if (hasUnsavedDraft) {
setAsking(true)
return true
}
leave()
return true
}, [hasUnsavedDraft, leave])
const stay = useCallback(() => setAsking(false), [])
const discard = useCallback(() => {
setAsking(false)
leave()
}, [leave])
useEffect(() => {
if (Platform.OS === 'web') {
return
}
const subscription = BackHandler.addEventListener('hardwareBackPress', requestBack)
return () => subscription.remove()
}, [requestBack])
return { confirmingDiscard, requestBack, stay, discard }
}
@@ -0,0 +1,277 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RouteDependencies = {
storage: Map<string, string>
routes: { pathname: string; params?: Record<string, string> }[]
panels: { hostId: string; worktreeId: string; name?: string }[]
previews: unknown[]
/** `mount:<pathname>` / `unmount:<pathname>`, which is the only thing that tells a remount from
* a prop update — and a remount is what drops the old session's bridge and its grants. */
lifecycle: string[]
params: Record<string, string | string[] | undefined>
}
const dependencies = vi.hoisted((): RouteDependencies => ({
storage: new Map(),
routes: [],
panels: [],
previews: [],
lifecycle: [],
params: {}
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async (key: string) => dependencies.storage.get(key) ?? null,
setItem: async (key: string, value: string) => {
dependencies.storage.set(key, value)
}
}
}))
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
// `firstParam` lives in the source-control barrel, which imports two dozen icons from a 1.14.0
// lucide barrel that re-exports a `LucideProvider` its own context.mjs does not have. Metro and the
// web builder each paper over it; nothing under test here renders an icon, so any name will do.
vi.mock('lucide-react-native', () => ({
ArrowDown: vi.fn(),
ArrowDownUp: vi.fn(),
ArrowUp: vi.fn(),
Check: vi.fn(),
CloudUpload: vi.fn(),
GitBranch: vi.fn(),
GitPullRequestArrow: vi.fn(),
History: vi.fn(),
RefreshCw: vi.fn()
}))
vi.mock('../files/MobileFileExplorerPanel', () => ({
MobileFileExplorerPanel: (props: { hostId: string; worktreeId: string; name?: string }) => {
dependencies.panels.push(props)
return null
}
}))
vi.mock('../files/MobileFilePreviewScreen', () => ({
MobileFilePreviewScreen: (props: unknown) => {
dependencies.previews.push(props)
return null
}
}))
vi.mock('./MobileWebShellScreen', async () => {
const React = await import('react')
return {
MobileWebShellScreen: (props: {
hostId: string
route: { pathname: string; params?: Record<string, string> }
}) => {
dependencies.routes.push(props.route)
// Empty deps on purpose: keyed on the pathname this would re-fire on a prop update and read
// exactly like a remount, which is the one thing it exists to tell apart.
const mountedAs = React.useRef(props.route.pathname)
React.useEffect(() => {
const pathname = mountedAs.current
dependencies.lifecycle.push(`mount:${pathname}`)
return () => {
dependencies.lifecycle.push(`unmount:${pathname}`)
}
}, [])
return null
}
}
})
import { BRIDGE_ROUTE_PATHNAME_PATTERN } from './bridge/bridge-caps'
import MobileFileExplorerScreen from '../../app/h/[hostId]/files/[worktreeId]'
import MobileFilePreviewRoute from '../../app/h/[hostId]/files/preview/[worktreeId]'
async function renderExplorer(): Promise<void> {
await act(async () => {
create(createElement(MobileFileExplorerScreen))
})
}
async function renderPreview(): Promise<void> {
await act(async () => {
create(createElement(MobileFilePreviewRoute))
})
}
beforeEach(() => {
dependencies.storage.clear()
dependencies.routes.length = 0
dependencies.panels.length = 0
dependencies.previews.length = 0
dependencies.lifecycle.length = 0
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', name: 'my worktree' }
Object.assign(globalThis, { __DEV__: true })
dependencies.storage.set('orca:mobileWebShellEnabled', 'true')
})
describe('the native file explorer route that hands off to the shell', () => {
it('opens the shell on this screen, with the name as the search half', async () => {
await renderExplorer()
expect(dependencies.routes).toEqual([
{ pathname: '/h/host-1/files/wt-1', params: { name: 'my worktree' } }
])
})
it('renders the native panel while the flag read is still settling', async () => {
await renderExplorer()
expect(dependencies.panels[0]).toEqual({
hostId: 'host-1',
worktreeId: 'wt-1',
name: 'my worktree',
embedded: false
})
})
/**
* A repeated query key, which expo-router answers with an array.
*
* A bare read puts that array straight into a template, where `String(['a','b'])` is `a,b` and
* `encodeURIComponent` makes it `a%2Cb` — one segment, so the bridge's segment rule accepts it
* and the shell opens a page for a host that does not exist. `firstParam` is what the other
* switches read through, and it takes the first value the way the native screen below does.
*/
it('takes the first value of a repeated param, not the joined array', async () => {
dependencies.params = {
hostId: ['host-a', 'host-b'],
worktreeId: ['wt-1', 'wt-2'],
name: ['first', 'second']
}
await renderExplorer()
expect(dependencies.routes).toEqual([
{ pathname: '/h/host-a/files/wt-1', params: { name: 'first' } }
])
const pathname = dependencies.routes[0]?.pathname ?? ''
expect(pathname).not.toContain('%2C')
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(pathname)).toBe(true)
})
it('stays native for a dot-segment id the bridge would refuse', async () => {
for (const hostId of ['.', '..']) {
dependencies.params = { hostId, worktreeId: 'wt-1' }
dependencies.routes.length = 0
await renderExplorer()
expect(dependencies.routes, hostId).toEqual([])
}
})
it('encodes both dynamic segments, so a deep-linked id stays one segment each', async () => {
for (const hostId of ['a?b', 'a#b', 'a b', 'a/b', 'a\\b']) {
dependencies.params = { hostId, worktreeId: 'wt/1' }
dependencies.routes.length = 0
await renderExplorer()
const pathname = dependencies.routes[0]?.pathname ?? ''
expect(pathname, hostId).toBe(
`/h/${encodeURIComponent(hostId)}/files/${encodeURIComponent('wt/1')}`
)
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(pathname), hostId).toBe(true)
}
})
it('renders the native panel with the flag off, which is every store build', async () => {
dependencies.storage.set('orca:mobileWebShellEnabled', 'false')
await renderExplorer()
expect(dependencies.routes).toEqual([])
})
/**
* A route change is a new session, and the old one's bridge must not outlive it.
*
* The host captures the grants its session was 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 it, and only a key guarantees one.
*/
it('remounts the shell when the route changes rather than updating it', async () => {
let renderer: ReturnType<typeof create> | null = null
await act(async () => {
renderer = create(createElement(MobileFileExplorerScreen))
})
dependencies.params = { hostId: 'host-2', worktreeId: 'wt-9' }
await act(async () => {
renderer?.update(createElement(MobileFileExplorerScreen))
})
expect(dependencies.lifecycle).toEqual([
'mount:/h/host-1/files/wt-1',
'unmount:/h/host-1/files/wt-1',
'mount:/h/host-2/files/wt-9'
])
})
it('remounts when only a param changes, which the page has no other way to learn', async () => {
// The page reads its route once, out of `init`. Same pathname, different label: without the
// params in the key the shell stays mounted and the page never hears about it.
let renderer: ReturnType<typeof create> | null = null
await act(async () => {
renderer = create(createElement(MobileFileExplorerScreen))
})
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', name: 'renamed' }
await act(async () => {
renderer?.update(createElement(MobileFileExplorerScreen))
})
expect(dependencies.lifecycle).toEqual([
'mount:/h/host-1/files/wt-1',
'unmount:/h/host-1/files/wt-1',
'mount:/h/host-1/files/wt-1'
])
})
})
describe('the native file preview route that hands off to the shell', () => {
beforeEach(() => {
dependencies.params = {
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath: 'docs/readme.md'
}
})
it('opens the shell on this screen, with the file path as a param', async () => {
await renderPreview()
expect(dependencies.routes).toEqual([
{
pathname: '/h/host-1/files/preview/wt-1',
params: { relativePath: 'docs/readme.md', source: 'worktree' }
}
])
})
it('remounts for another file in the same worktree, which keeps the pathname', async () => {
let renderer: ReturnType<typeof create> | null = null
await act(async () => {
renderer = create(createElement(MobileFilePreviewRoute))
})
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', relativePath: 'docs/other.md' }
await act(async () => {
renderer?.update(createElement(MobileFilePreviewRoute))
})
expect(dependencies.lifecycle).toEqual([
'mount:/h/host-1/files/preview/wt-1',
'unmount:/h/host-1/files/preview/wt-1',
'mount:/h/host-1/files/preview/wt-1'
])
expect(dependencies.routes.at(-1)?.params?.relativePath).toBe('docs/other.md')
})
it('remounts the shell when the route changes rather than updating it', async () => {
let renderer: ReturnType<typeof create> | null = null
await act(async () => {
renderer = create(createElement(MobileFilePreviewRoute))
})
dependencies.params = { hostId: 'host-2', worktreeId: 'wt-9', relativePath: 'a.md' }
await act(async () => {
renderer?.update(createElement(MobileFilePreviewRoute))
})
expect(dependencies.lifecycle).toEqual([
'mount:/h/host-1/files/preview/wt-1',
'unmount:/h/host-1/files/preview/wt-1',
'mount:/h/host-2/files/preview/wt-9'
])
})
})
@@ -17,8 +17,16 @@ const ROUTE = 'app/h/[hostId]/web.tsx'
const HOST_ROUTE = 'app/h/[hostId]/index.tsx'
const AGENT_HISTORY_ROUTE = 'app/h/[hostId]/agent-history/[worktreeId].tsx'
const TASKS_ROUTE = 'app/h/[hostId]/tasks.tsx'
const FILES_ROUTE = 'app/h/[hostId]/files/[worktreeId].tsx'
const FILES_PREVIEW_ROUTE = 'app/h/[hostId]/files/preview/[worktreeId].tsx'
/** One entry per screen the flag can switch to the page, which is what a review reads. */
const SWITCHED_ROUTES = [HOST_ROUTE, AGENT_HISTORY_ROUTE, TASKS_ROUTE]
const SWITCHED_ROUTES = [
HOST_ROUTE,
AGENT_HISTORY_ROUTE,
TASKS_ROUTE,
FILES_ROUTE,
FILES_PREVIEW_ROUTE
]
const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx'
/** Every tree that ships in the app bundle, with the floor each must clear. `modules` is two files,
* but it is where the native view lives and so the easiest place for a second reader to hide. */
@@ -57,9 +65,9 @@ describe('who touches the hybrid shell flag', () => {
expect(paths).toContain(DEFINITION)
expect(paths).toContain(FLAG_HOOK)
expect(paths).toContain(ROUTE)
expect(paths).toContain(HOST_ROUTE)
expect(paths).toContain(AGENT_HISTORY_ROUTE)
expect(paths).toContain(TASKS_ROUTE)
for (const route of SWITCHED_ROUTES) {
expect(paths).toContain(route)
}
expect(paths).toContain(DEVELOPER_ROW)
expect(paths).toContain(SHELL_VIEW)
const trees = Object.keys(TREES)
@@ -82,7 +90,9 @@ describe('who touches the hybrid shell flag', () => {
it('reaches the switched routes through that hook and no others', () => {
// Each switched route is a screen the flag decides the renderer of, and one more is one more
// place a dark feature could turn itself on. The list grows once per domain series, in the PR
// that lists the route in MOBILE_WEB_PAGE_ROUTES, and never as a side effect of anything else.
// that switches the route file to MobileWebShellScreen, and never as a side effect of anything
// else. A switched route is inert until MOBILE_WEB_PAGE_ROUTES lists it as well, so an entry
// here can land a PR ahead of that one.
expect(filesContaining('useMobileWebShellEnabled')).toEqual(
[FLAG_HOOK, ROUTE, ...SWITCHED_ROUTES].sort()
)
+24
View File
@@ -0,0 +1,24 @@
/**
* Haptics inside the shell's page: nothing at all.
*
* expo-haptics has a web build, and that is the problem rather than the solution. 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 again, 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 the page has no haptics. A phone holding the page is a phone whose native app is right there
* with the real ones, and a missing tap feedback is worth less than a tap that does not register.
*
* Same five names as the native file, because that is what makes this a substitution: an export
* added there and missing here is a build error in the bundle, not a silent no-op.
*/
export function triggerMediumImpact(): void {}
export function triggerSelection(): void {}
export function triggerSuccess(): void {}
export function triggerError(): void {}
export function triggerEdgeBump(): void {}
+12
View File
@@ -25,6 +25,18 @@
"file": "app/h/[hostId]/index.web.tsx",
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
},
{
"file": "src/platform/haptics.web.ts",
"reason": "expo-haptics has a web build that fakes an iOS 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 that stray click, and the file explorer calls triggerSelection on every row tap. The page has no haptics instead; the bundle test reads the shipped bytes for the shim."
},
{
"file": "app/h/[hostId]/files/[worktreeId].web.tsx",
"reason": "The shell renders this page for the file explorer, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser — reproduced in the render check, which painted that console error instead of the route."
},
{
"file": "app/h/[hostId]/files/preview/[worktreeId].web.tsx",
"reason": "The shell renders this page for the file preview, for the same reason as the explorer beside it: no nested shell, and a native file that reaches requireNativeViewManager at import. Normalizing the route params stays here, because the page reads them back out of its own URL exactly as the native screen reads them out of the router."
},
{
"file": "app/h/[hostId]/agent-history/[worktreeId].web.tsx",
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."