* fix(session): persist defaultTerminalTabsAppliedByWorktreeId
Host persist snapshots wrote tabs but omitted this write-once map, and
full session replaces / hydration treated omission as "never applied".
Union the marker across persist and hydrate so default terminals are not
re-spawned on every launch or re-attach.
Fixes#18117
* fix(runtime): stop re-seeding a runtime-owned workspace the user emptied
Focusing a workspace owned by a remote runtime created a terminal every
time, and sometimes two.
The mirror could never record the closed-last-terminal state. A host
snapshot with no terminals produced `nextTerminalTabs === null`, which
`withWorktreeEntry` turns into a deleted key -- and a missing row is
exactly how every seeder spells "never initialized"
(initial-terminal.ts). Keep an explicit empty row instead, so the remote
path reads the same tombstone the local one already honours. A worktree
that never had a terminal still gets no row, because `sameTerminalTabs`
treats a missing row and an empty one as equal; removal frames and
synthesized unpublished frames keep deleting, since neither is evidence
the user emptied anything.
The duplicate had a second cause. `requestedInitialTerminal` was a `let`
inside the session-tabs subscription closure, so "one focus creates at
most one terminal" held only for as long as that closure lived. Its
effect re-runs whenever the environment, connection generation, pairing
revision, or session-ready flag settles -- all of which move during a
workspace switch -- so a second closure re-armed the flag while the first
create was still in flight. That is the asymmetry in the report: one
terminal when arriving from the landing screen, two when arriving from
another workspace. Latch the bootstrap per worktree in a module-scoped
set instead, modelled on web-runtime-wake-terminal-respawn.ts, released
when the create settles. The closure flag stays alongside it so a failed
create still does not retry on every later frame of the same
subscription.
Fixes STA-6173.
* fix(runtime): harden the runtime-owned initial-terminal bootstrap latch
Follow-up on the STA-6173 fix, addressing restore-time safety gaps found in
review.
- Decline the bootstrap on a synthesized unpublished frame
(`UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH` at version 0). That frame is the
runtime saying "ask me later", not a host with zero terminals; seeding on it
can duplicate a pane the host is about to republish after a restart. This is
the same "ask me later" frame the tombstone write already refuses to treat as
the user emptying the workspace.
- Release the module-scoped bootstrap latch on worktree tracking teardown and
environment teardown, mirroring web-runtime-wake-terminal-respawn. A create
RPC that never settles during a disconnect would otherwise leave the
per-worktree key set and suppress the next bootstrap after reconnect.
Tests:
- New per-worktree and per-environment latch-release cases.
- New "unpublished frame declines" bootstrap case.
- New hook-level regression pinning the second defect end to end: a forced
active-subscription re-run while the first create is in flight seeds exactly
one terminal (two on the pre-fix tree).
* fix(runtime): key the initial-terminal bootstrap latch per environment
Addresses review on the STA-6173 hardening.
- Key the bootstrap latch by (environment, worktree), not worktree alone. A
worktree id is `repoId::path` with no host component, so the same id can be
live on two paired runtimes at once. The latch was cleared wholesale on any
environment teardown, so tearing down environment A released environment B's
in-flight key and a fresh B subscription could seed a duplicate — the STA-6173
defect through another door. Environment teardown now clears only its own
keys; worktree teardown clears only that (environment, worktree).
- Hold the latch after a successful create until a mirrored `tabsByWorktree` row
exists. The snapshot refresh the create awaits can resolve on an empty,
unconfirmed frame that leaves no row; releasing then let a later effect re-run
seed a second terminal. A failed create still releases for retry. The latch
claim/create/release now lives in web-runtime-initial-terminal-bootstrap-dispatch.ts,
keeping active-session-subscription within its line budget.
Tests (each mutation-tested against its own regression):
- Cross-environment: a create for env B in flight, env A torn down, a fresh B
closure must still decline. Fails when env teardown sweeps all environments.
- Hold-until-row: a create that resolved without mirroring a row must not let an
effect re-run seed again. Fails on unconditional release.
* fix(runtime): release the bootstrap latch when the create returns failed
createWebRuntimeSessionTerminal never throws: the operation catches RPC and
network failures and returns `{ status: 'failed' }`. The dispatch helper
released the latch only from `catch` (dead for that path) or once a
tabsByWorktree row existed (false after a failure), so a failed create left
the latch held and suppressed every later auto-seed for that environment's
worktree until teardown -- the opposite of its own doc comment.
Capture the outcome and release on a returned failure as well, so the next
focus can retry.
Regression: a create that resolves `{ status: 'failed' }` followed by an
effect re-run must create again. Fails on the previous release condition.
* fix(runtime): release a parked bootstrap on the mirror's next frame
The previous commit released the latch on a returned failure, but a create
that *succeeded* with no mirrored row yet had no release at all: the
row-conditional check was the only exit for the success path, so a host that
accepted the tab while the mirror never got a frame held the latch until
environment teardown and suppressed every later auto-seed for the worktree.
Give the latch two phases. `creating` blocks other closures while the RPC is
in flight. A success with no row is parked as `awaiting-mirror` instead of
held, and the next frame the mirror accepts for that worktree releases it --
that frame is the mirror's answer either way (a row now exists and the
predicate declines on its own, or the host genuinely has no terminal and a
retry is right). A create still in flight keeps its claim: releasing it on a
frame would reopen the re-armed-closure race the latch exists to close.
Also correct the closure-flag comment: `requestedInitialTerminal` is set only
after the dispatch resolves, so a thrown create never sets it and a later
frame may retry. The flag records that this subscription already owned a
create; it never described a failed one.
Regressions, each mutation-tested against its own term:
- success with no row, then the mirror's empty answer, then a fresh closure
-> must create again (fails when success-with-no-row is held instead of
parked, and when the subscription does not call the frame release)
- a mirror frame releases an awaiting-mirror claim but never a pending
create (fails when the release ignores the phase)
* fix(runtime): let a failed create retry inside its own subscription
The returned-failure release freed the module latch but the caller still
latched its closure-local requestedInitialTerminal whenever the dispatch
reported it owned the create, including a create that returned
{ status: 'failed' }. A thrown failure never set it and retried on the
next frame; since every RPC and network failure is reported as a return,
the live path was the one that suppressed the whole subscription. The
dispatch now reports false for a failure whichever way it arrives.
* fix(runtime): close the second re-seed door on an emptied workspace
ensureWebRuntimeWorktreeTerminalAfterWake read tabsByWorktree through
`?? []`, and the only guard that could have seen presence was &&-gated on
the value that erased it, so it could never fire for a tombstone. It runs
on every activateAndRevealWorktree, not only after a wake, and a
tombstoned workspace routes exclusively here: the stream-frame path
returns at localTerminalCount === 0 before reaching its own guard. So the
workspace was re-seeded on every focus regardless of the mirror fix.
Two states shared one line and now do not. With no rows the workspace is
being seeded for the first time and the decision goes to
shouldAutoCreateInitialTerminal with presence read by Object.hasOwn; with
rows present the question is whether a woke workspace's chrome outlived
its PTYs, which the tombstone says nothing about. Both arms are pinned,
because reverting only the respawn arm broke no existing test.
* fix(runtime): stop the worktree-id re-key inventing a tombstone
canonicalizeTerminalSessionWorktreeId read the source row through `?? []`
and always wrote the target one, so a workspace with NO tabsByWorktree row
came out the other side with an explicit empty one. That is the
closed-last-terminal tombstone, and this PR's new activation-path reader
honours it, so the re-keyed workspace never gets its initial terminal.
Guarded on the source row's presence, which is the guard the sibling
keyed maps six lines below already use. Mutating the guard to test
emptiness instead of presence breaks the assertion that a real tombstone
still survives the re-key, which is the distinction that matters.
* fix(runtime): reconcile session tab bootstrap integration
* fix(runtime): preserve terminal wake launch options
* test(session): annotate cross-project persistence fixture
* test(session): remove obsolete typecheck suppression
* test(session): keep persistence regression in renderer project
* refactor(runtime): remove inert snapshot recovery wrapper
* fix(runtime): keep removal-frame import merge-safe
* fix(runtime): deduplicate merged removal-frame import
---------
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* test(mobile): certify the files page closure, 28 families and 125 goldens
C3.2. The closure is re-derived at this base from the entries esbuild compiles —
the two `.web.tsx` files — and matches the design exactly: 28 families, 125
goldens, C1's 22 a strict subset, 6 families and 22 goldens added. Explorer
3441 modules / 304 local / 10 under `src/files`, preview 3666 / 330 / 19, union
342 local. No `mobileWeb.*` family appears, which is the tell that the native
switch was not measured.
Two tables at the route seam, as C2.6 splits its own: the explorer's one family
and the preview's five are separate evidence for two routes with separate
grants, and `c3-page-closure.ts` only spreads them over C1's.
C1's 22 families are inherited verbatim, not re-derived. Measured here, C2's
rule disagrees with 13 of those 103 pins — all 7 in `tasks.smart-source-search`,
all 5 in `host-worktree-refresh`, and `worktree-catalog-snapshot`. C5's
docstring says 10; corrected in this commit, comment-only. Every family C3
shares with C2 and with C5 is asserted equal object for object, and the three
committed pin files disagree on nothing.
Class totals: identical 66, result-absent-settlement 47, params-undefined 7,
result-absent-stream-release 3, write-ordinal 2. Pinned beside the per-id walk
because a table built wrong in a self-consistent way passes the walk.
Red-first, both halves: changing one verdict fails the totals and the
inheritance check by name; dropping a golden fails drift, totals and the census
count together.
What 125 certified does not say: `host-worktree-refresh`, inherited from C1, has
no byte-identical golden at all, so its 5 hold a class and not bytes. All six
families C3 adds have at least one. No scenario replays a save twice, so
`files.writeTerminalArtifact` is certified for one round trip and not for
idempotency; and no golden here subscribes, because the domain opens no stream.
The `config/scripts` precondition now derives both files routes and compares
their union against the C1 + C3 tables, with a second case proving each route
reaches a strict part of it — without which the union would pass with one route
contributing nothing.
Also folds pullfrog's open nit: the render check's comment claimed
`toContain('readme.md')` proved the encoded round trip, which a truncated path
would also satisfy; the url assertion beside it is what proves it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): share the pin-source reader and finish C5's 10-to-13 correction
Two pullfrog findings on #21724.
`c5-page-closure.test.ts` still said the rule disagrees on 10 of the 103; only
`c5-page-closure.ts` moved in the last commit, so C5's two files disagreed with
each other and with C2's and C3's. The sentence now states 13 with the same
breakdown the other three carry. No assertion depended on the number. While
there, the comment records why C5 compares against the imported object rather
than the committed text and is still sound: it inlines its families instead of
spreading C1's, so there is no spread for an edited entry to launder through.
`pinsFromSource` was byte-for-byte identical in C2's and C3's suites — checked,
not assumed — and is now one module both import. It sits beside
`page-closure.ts` rather than inside it, named for what it reads: the module it
would have joined holds pure table arithmetic and this one reaches the file
system. The C2 suite's cases and assertions are unchanged; its diff is the
deletion of the copy and one import.
The shared reader keeps its teeth: making the wrapped-entry capture unmatchable
reds the inheritance check in both composed suites, which is the defect the
comment describes — three `result-absent-stream-release` pins once went missing
that way with an empty mismatch list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name which leg of the path round trip each assertion proves
Round-1 nits 3 and 4. A second commit rather than a fold into `dc1de6e728`:
that one is already committed, and amending is off the table.
The render check's comment claimed the url assertion was the proof of the round
trip. It is the outbound leg only — what the page encoded into its own history —
and a screen that mis-decoded the middle of the path satisfies it and the title
assertion both. The comment says that now, and points at where the inbound leg
is proved instead: `mobile-file-path-route-encoding.test.ts`, which reads each
hazard shape back out of the href, and `mobile-file-preview-route.test.ts`,
which drives the normalizer the screen reads its params through. Both files
checked to exist, and read, before being cited.
The 10-to-13 edit left a 127-character line in a file that wraps at 100;
reflowed. It was the only over-length line the C3.2 commits introduced — the
others in both files predate this branch, and `oxfmt` accepts them because it
does not reflow comments.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the inheritance case is blind to, and finish the reflow
Round-1 item 3, plus a correction to my own claim.
The "inherits C1's families whole" case reads C1's committed text, which makes
it independent of the spread but not of C1's file: edit a verdict there and both
sides of the comparison move together. The docstring now says that, says what it
does catch — a C3 half redeclaring an inherited family, which the spread would
otherwise take silently from the last table — and names where the C1-file edit
is caught instead.
Both measured here rather than taken from the review. Flipping
`settings-repo-metadata-icons` in C1's file leaves this case green and reds
seven others: the class totals and the exclusion counts in C2's suite and this
one, both cross-series agreements, and C5's own inheritance case, which compares
against an independent literal rather than a spread. Redeclaring
`settings.repo-metadata` in the preview half reds this case along with five
more.
I also said last round that the 127-character line was the only over-length line
these commits introduced. That was wrong: I checked the two files in that commit
rather than the branch. Six lines over the 100-column wrap came in across four
files, including two I had just written in `c3-page-closure.test.ts`. All six are
reflowed, and the check is now over every line the branch adds rather than over
the files I happened to touch last.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state both redeclaration shapes, or neither count
Round 2's low finding. The docstring said redeclaring `settings.repo-metadata`
in the preview half reds this case "along with five more". The reviewer is right
that the number is shape-dependent, and right about both figures.
Re-measured, no truncation this time: one golden under the family's name reds
seven, because it also shrinks the census to 114 and leaves that family with no
byte-identical golden; the family copied verbatim with a single verdict flipped
reds five, the census unmoved at 125. The docstring states both with the reason
they differ, and says plainly that the count is not the claim — the load-bearing
half is that the spread takes the last table's entry and this case is what sees
it, which holds in both shapes.
The six came from reading a `head -6` of the failure list as the whole of it.
That is the same mistake in miniature as the one this file's own comments warn
about: an empty-looking result that was only a truncated one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the Back control in every page-served screen
The C2.7 device proof could only reach the tasks Back by tapping the
native control's coordinates: inside the shell there is no native chrome
behind it, so a bare Pressable is absent from the accessibility tree and
a screen reader has nothing to announce. The agent-history panel's Back
carries a label but no role; the tasks one carries neither.
Both gain `accessibilityRole="button"`, and the tasks one the app's own
wording, `accessibilityLabel="Back"` — the spelling its thirteen
siblings use, including the agent-history panel this screen sits beside.
The host screen's Back already carried both and did not move.
The new census is a rule rather than a count: it finds every Back
affordance under the screen tree each page route serves — a Pressable
rendering a ChevronLeft that either carries the back-button style or
calls back — and requires a button role and a Back-shaped label on each.
Its coverage assertion reads MOBILE_WEB_PAGE_ROUTES, so a route
registered without a screen entry fails here, the way the flag census
fails for a switched route. Red first on both offenders by name.
Two tasks parity hashes move, and only the two a JSX prop must move:
`semantics` 3,272 -> 3,274 for the widened host signature and the two
new runtime strings, and the render-token stream 35,195 -> 35,203 for
the eight tokens the two attributes are. Hooks, statements,
declarations and styles hold, which is the evidence that nothing
executable changed. Recorded in the test's comment.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): judge a Back control by what it does, and name the files pair
Round 1 found the census discriminating on the ChevronLeft glyph, which
separates nothing: dismisses sit in the same header slot with the same
back-button style, so the rule claimed a dismiss and told it to be
called Back, while the explorer's Close escaped only by drawing an X.
The predicate is now the behaviour. A Back control is a Pressable whose
press handler reaches a back call — a `back()`, `goBack()` or `onBack()`
call, a handler named for one, or a local handler whose declaration
makes one — or whose label already says Back. A control matching
neither is outside the rule whatever it renders. The reviewer's
mutation, a dismiss with the back-button style wrapping a ChevronLeft
behind `onDismiss`, is claimed by the old predicate and passed over by
this one, which is the whole of the difference.
A spread now reads as unknown rather than absent, so a Pressable that
hides these props behind one is a Back control that fails both rules
instead of a control the scan never sees. No Pressable in the five
trees spreads today, so the branch is inert and load-bearing.
C3.1 registered the files explorer and preview, so the coverage
assertion went red on its merge, as designed. Both screens join
PAGE_SERVED_SCREENS and both Back controls gain
`accessibilityRole="button"`; they already carried `Back to session`
and `Back to files`. The tasks Back gains the `hitSlop={8}` its four
siblings carry, so its touch target is no longer the glyph alone.
Nine mutations: the dismiss stays out; a dropped role on each of the
five controls and a dropped label on the tasks one go red by name; a
spread fails both rules; a dropped table entry fails coverage; and
dropping the host screen's label — its only detector, since
`actions.leaveHost` is opaque — empties that tree and fails the
presence assertion rather than passing in silence.
Two parity hashes move for the one added prop and no count does:
`semantics` holds at 3,274 lines with one changed, the host signature
gaining `hitSlop`, and the render-token stream gains that attribute's
four tokens, 35,203 -> 35,207. Recorded in the test's comment.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): assert a Back per page route, not per screen directory
Round 2 found the presence assertion reading directories while two
routes share `src/files`, so the explorer's Back answered for the
preview's as well: rewriting the preview's control to `onPress=
{handleClose}` with `accessibilityLabel="Close"` left that route with
no accessible Back and the suite green. Reproduced exactly, then made
red by this change, which names the route and the module it failed in.
Each PAGE_SERVED_SCREENS entry now names the module that renders its
route's Back, and presence is asserted per entry. The rules still read
whole trees, so a Back added beside a screen is ruled as well as the
screen's own; the tree is derived from the module's directory rather
than declared twice. The failure prints the offending list instead of
comparing two values a reader cannot tell apart.
The doc comment claimed the preview was carried by the label half of
the predicate. It is not: `requestBack` is named for what it does, so
the preview is found behaviourally like the explorer, the tasks screen
and the agent-history panel. Dropping the label from each of those
three leaves it detected and failing the naming rule, which is the
evidence. Only the host screen depends on the label half, because
`actions.leaveHost` is a member access this rule does not follow.
The admitted residual gap stays admitted and the predicate is not
widened for it: a second Back control in a screen that already has one,
with an opaque handler and a label that does not say Back, is still
outside what this rule sees.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* test(mobile): read a page closure's run totals through one reader
The C5 gate counted the run's classes inline. C2 needs the same count over its
own closure, and two spellings of "what the run tallied" can disagree while both
stay green, so the loop moves next to `pageClosureTotals` where the table-side
count already lives.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): certify the tasks page closure, 70 families and 266 goldens
C2 moves the tasks screen to the web, so the goldens recorded at a call site
inside `app/h/[hostId]/tasks.web.tsx` and `app/h/_layout.tsx` are the ones whose
divergence would be this domain's. Each is pinned by id: the suite's own counts
run over 787, where one of the other 521 can pay for a closure golden that
stopped replaying.
C1's 22 families are inherited verbatim rather than re-derived — C2's rule
disagrees with them on 10 of the 103 — and the rule decides only the 48 this
domain adds. The pin is split at the domain's seam, one work item opened versus
choosing which to open, because the table is 409 lines of data and `max-lines`
is not a thing to disable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): correct the C2 pin's inheritance count and census scope
Two comments overstated what was measured. The rule disagrees with 13 of C1's
103 inherited pins, not 10 — the 10 was copied from C5's file, which carries the
same error over the same 22 families — and the breakdown is now named so the
number can be re-derived rather than trusted.
The census reads the committed table and does not re-derive the closure, so a
golden arriving in a pinned family is caught while a new family entering the
closure is not. That was true and unsaid, which is the worse of the two.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test: derive the tasks page closure's family set instead of trusting the pins
Round 2 folds, three.
The pin tables walk the families they already hold, so a scenario recorded at a
call site the route already imports lands in a family nobody pinned and every
assertion stays green. `mobileWebAppRouteClosure` runs in a quarter second and
`config/scripts` already imports it, so the derivation is now a test: the family
set the closure reaches must equal the union of the three committed tables.
C2's inheritance check read the object its own table spreads, which cannot
disagree with itself; it now reads C1's file as text. What that does and does
not hold is written down, because a verdict edited inside `c1-page-closure.ts`
is green there either way — C2 inherits whatever C1 commits. The gate's C1 block
gains the run-totals assertion C5 and C2 already had, which is the check that
edit does fail.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* ci: run the page-closure family check in the job that installs mobile deps
Its closure half asks `mobileWebAppDependenciesPresent()` first, so outside the
`mobile_web_app` job it skips itself and the precondition it exists to be never
runs. That job sets `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED`, which turns the same
question into a failure.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)
`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.
Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.
The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)
Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.
Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.
The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.
No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)
The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.
Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.
The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.
The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.
A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)
The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.
A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.
Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.
Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)
The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.
One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.
No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)
`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`react-native-webview` is a native component with no browser counterpart:
importing it runs a codegen lookup that throws, and the route manifest imports
every route, so one such import takes the whole page down rather than one
diagram.
The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.
Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.
Registered in the overrides, whose gate fails on an unlisted `.web.*` file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)
The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.
The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.
The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.
The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.
The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)
`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.
Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.
The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)
Four from round 1.
The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.
The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.
`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.
`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.
No parity hash moved: none of these files is in the refactor-parity source
set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(config): use endsWith for the clipboard module check
The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)
Five from round 2, two of them real.
The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.
The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.
Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(pi): let Source Control AI use Pi configured default
When Orca runs Pi for automatic branch names or commit messages without an explicit model override, omit --model so Pi resolves its configured provider. Preserve explicit discovered model selection and add regression coverage.
* fix(pi): preserve discovered fallback for non-Pi agents
Keep the configured-default sentinel behavior limited to agents whose default is the explicit CLI sentinel. Other dynamic agents still fall back to the first discovered model when their static default is unavailable.
* test(pi): pin configured-default dry-run arguments
Prove Source Control AI does not render the Pi configured-default sentinel as a literal model argument, and assert explicit model flag pairing positionally.
#21674 re-recorded the corpus with `baseline` set to its own branch commit
`1006ad670a`, which the squash merge left unreachable from main. The pin
guard on the push to main fails, and so does the pin job on every open
pull request, since the merge ref cannot reach that commit either.
Repin to main's tip `e8a7be4ce2` and re-record from that tree. Every
golden's body is byte-identical to the one #21674 recorded; only the two
header lines move: `baseline` (788 files) and `lockfileSha256` (778
files), the latter because #21652 changed `mobile/pnpm-lock.yaml` after
that branch recorded.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Merged after fresh run 35448889017 passed all required checks, including static analysis, typecheck, package jobs, all test shards, changed E2E, Docker SSH E2E, and verify.