Commit Graph
218 Commits
Author SHA1 Message Date
Jinwoo Hong ac4dc6599b feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:53:58 -04:00
Jinwoo Hong b8f3b1ec00 feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) (#21501)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:21:48 -04:00
Jinwoo Hong 381a3da46f feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449)
* refactor(mobile-web): share the bundle manifest assembly with a second builder

Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and
the helpers the Phase C app builder needs become exports. No behaviour change
to the shipped bootstrap bundle.

The CRLF guard grows two exemptions it needs once it is pointed at mobile/src:
the image and font extensions .gitattributes already pins -text, and the
gitignored webview engine modules the postinstall writes.

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

* feat(mobile): web entry for the host route tree, and its two transport siblings

The entry mounts app/h on react-native-web through expo-router's own ExpoRoot.
It lives inside mobile/ so one React resolves, and supplies RpcClientProvider
itself: the route tree starts below the native root layout that owns it.

route-manifest.ts is a real typed module whose body the builder replaces --
esbuild has no require.context. A virtual specifier would need an ambient
declaration and would leave the entry unchecked.

Two .web.* siblings, both listed with a reason in web-overrides.json: the
transport substitution point (a placeholder client until C0.4 lands
BridgeRpcClient) and the device token store, whose native path imports
expo-secure-store, which is {} on web.

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

* feat(build): build:mobile-web:app, the phone's host routes bundled for the web

Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app,
with the same manifest and the same two-scratch-build determinism check. Dark:
build:mobile-web, packaging and the A2 census are untouched, and C1 is what
flips build:release.

Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin
hashed assets rather than data: URLs, because the shell's CSP sets img-src
'self'; the render check under that exact header is what found it. The script is
referenced root-absolute for the same reason a <base> tag cannot be used: the
document is served at every route depth and base-uri is 'none'.

The budget sits below the contract's per-asset ceiling so growth trips a build
rather than a refused asset on a phone. esbuild splitting does not lower it:
one entry with only static imports emits one chunk (measured).

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

* fix(mobile): let React Native Web paint under the shell CSP

RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so
style-src 'self' blocks every rule and the page renders unstyled. Measured, not
predicted: the render check serves the document under this exact header and
reported the violation.

'unsafe-inline' is granted to style-src and nothing else. script-src 'self'
holds, which is the directive that decides whether page code can arrive any way
other than as a fetched same-origin script. The test now pins that scoping
rather than rejecting the token everywhere.

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

* ci: prove the Route A app bundle on every PR

A dedicated job, for the same reason the browser provider has one: it needs
mobile/node_modules and a real browser, and the sharded test matrix would pay
for both on every shard. It builds the bundle, verifies it, and runs the
builder, override-census and render suites. It ships nothing.

The mobile_web_app signal is lifted out of should_run the way static_analysis
is. A mobile-only diff is desktop-irrelevant and skips every gated job, and
that is exactly the diff that changes the page this job builds.

Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml
join the installer cache keys in the two workflows that build an installer off
a hashFiles key, since beforePack requires out/mobile-web and a mobile-only
change must miss those caches rather than reuse a stale build.

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

* test(build): pin the shipped builder against the app builder's own module name

The assertion named a specifier that no longer exists, so it held vacuously.

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

* test(mobile): assert the RN Web style-src grant in the Swift checks

The Swift twin of the Kotlin CSP test still required style-src 'self' and
no unsafe-inline anywhere, so it trapped on the approved grant.

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

* test(build): make the Route A render check name what each route paints

The check asserted only "some html, no errors", which expo-router's Unmatched
screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green.
Each route now asserts content only its own component produces, and the
unmatched case asserts the screen positively so the negatives discriminate.

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

* test(build): read the shell CSP past the comments that quote directives

Both constants document themselves with // comments containing quoted
directive text, which the quoted-string scan picked up as directives. One
parser now drops comment lines, and iOS and Android go through it.

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

* feat(build): honour a .web.* route sibling in the app bundle

Routes were imported by absolute path with the extension, so esbuild's
resolveExtensions never applied and a .web.tsx under app/ was dead code the
census still accepted. The manifest now carries a key and a module: the key
stays the native filename so the URL does not move, and the module is the web
sibling when one exists.

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

* test(build): tie each named shim to the esbuild option that implements it

The shim list was asserted against a literal copy of itself, which passes
however the build is configured. Each entry now carries an appliesTo that
reads its own option, checked against the real options object.

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

* chore(build): line up the CRLF exemptions, the budget comment, and the job scope

The builder loads .gif as a file but neither .gitattributes nor the CRLF scan
exempted it, so the blanket eol=lf pin would have rewritten one. A test now
keeps the two lists in step. The Phase C byte budget's comment sat on the
asset count, and a root package.json edit could change build:mobile-web:app
without running the job that proves it.

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

* style(build): satisfy the index-check lint rule in the CSP parser

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

* ci: key the installer caches on the mobile page trees too

beforePack builds the mobile web bundle into the installer. Today those bytes
are Phase A's, which src/** already covers, but once C1 flips the entry to
mobile/app a page-only change would hit a cache holding a stale installer.

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

* test(build): skip the bundling tests where mobile dependencies are absent

The sharded `test` job collects config/scripts/**/*.test.mjs and installs no
mobile dependencies, so the two new suites failed there on "Could not resolve
react-native-web". They now skip themselves with a message naming the job that
runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing
install fails it instead of skipping everything it exists to prove.

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

* fix(build): scan mobile/packages in the .web.* census

The census claimed the app entry never resolves into packages/, but the
dictation hook imports @orca/expo-two-way-audio and the built script carries
ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and
planting a .web.* in each scanned tree proves the scan is not passing because
a tree happens to be empty.

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

* test(build): assert the route exclusions against a tree that has them

mobile/app holds no test, spec or +api file, so the exclusion rule was
asserted against a tree it could not fire on. A scratch tree plants one of
each; dropping the rule now fails this test.

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

* test(build): 404 unknown file paths in the render check's page server

The server answered every path with the document, so pointing publicPath at
/wrong-prefix still rendered three green routes: the script is fetched from
the one prefix that is served. A path naming a file now has to come out of the
bundle, which is what the shell's manifest map does.

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

* test(build): cover the app bundle verifier's own checks

The verifier had no test. One doctors the buildId, which the packaged assert
catches; the other rewrites the tree so every digest still agrees and only the
two fresh builds can tell, which is what a stale out/ looks like. Deleting
either check now fails a test.

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

* chore(build): tidy the app bundle comments and the job's path prefixes

Drops an export nothing read, merges two comments that had drifted apart from
the constant they describe, and corrects the claim that the job runs on every
PR when it is path-gated. package.json leaves the prefix list because
GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it,
since the page resolves a .web.ts out of there.

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

* style(build): merge the duplicate node:fs/promises import in the census

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

* fix(mobile): redirect the hybrid shell route on the web page

app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls
requireNativeViewManager at import. In a browser that throws before React
mounts, and the route manifest imports every route statically, so one native
route left the whole page blank at every URL.

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

* test(build): fail the render check with the error that stopped the mount

The check waited on "#root has children" with Playwright's animation-frame
polling, so a route module that threw at import read as a bare 30s timeout
naming nothing. It now waits on a mount attribute the entry sets after the
router commits, polls on a timer, and races the wait against the first
uncaught error so the failure carries it.

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

* test(build): answer the favicon the render browser asks for

CI resolves the runner's Google Chrome, which requests /favicon.ico; the
bundled headless shell does not. The bundle carries no icon, so the server
answers 204 rather than turning a browser habit into a console error the
render assertions read as a page fault.

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

* test(build): settle the render check's uncaught-error race without rejecting

The entry throws during goto, before anything awaits the race, so a rejected
promise surfaced as an unhandled rejection beside the real failure. The same
signal now resolves with the error.

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

* chore(mobile): list the page transport in the raw request port inventory

The placeholder client implements the port, so the boundary test counts it as
an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient
replaces it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 09:50:37 -04:00
Jinwoo Hong 47d107cf2e feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window

`useHostStatusGates` settled the same closed gates for a host that answered
`status.get` with no capabilities and for one whose status nobody could read:
both paths produced an empty capability list and an `ok` verdict. A caller that
walls on a missing capability cannot tell those apart, and the mobile web
bundle's wall is terminal, so it must never fire for the second.

`statusReadable` distinguishes them. `hostProtocolWindow` exposes the two
protocol numbers the hook already read for `evaluateCompat`, as the reply's own
fields, so the bundle wall can evaluate its own window without a second
`status.get`. Both are additive; no existing consumer changes.

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

* feat(mobile): add the hybrid shell flag and the generation path both loaders demand

`orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same
shape as the terminal autocomplete flag.

`generationDirectoryPath` converts the store's `file://` uri to the absolute
path the native shell view requires: both `MobileWebShellGeneration.load`
implementations refuse anything without a leading slash, and `expo-file-system`
only ever hands out uris.

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

* feat(mobile): the hybrid shell session as a pure reducer

Every decision the route makes, as `(session, event) -> (state, effects)`: the
capability wall, the lazy sweep and cache read, the manifest check, the cached
build-id hit that skips paging, the offline open with no compat check, and the
three recovery rules the native shell view's contract states.

Pure, so the rules are table tests rather than a simulator run. Two latches sit
beside the state because both outlive it: `retriedOnce` spans the delete and
refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced
by a `ready` under a new session id.

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

* feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4)

Wires the four Phase B and A pieces together and adds no decision of its own.
`h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native
`desktop-too-old` wall still applies above it. With the flag off — every store
build, since the only writer is a `__DEV__` Troubleshoot toggle — the route
redirects to `h/[hostId]` and the screen is never constructed, so nothing is
fetched, written or swept.

The runner owns only the impure edges and checks an epoch before every dispatch,
so an unmount, a host change or a retry abandons work in flight and aborts a
download that would otherwise hold four of the host's read slots. The native view
is keyed on the session id, which is what makes the reducer's remount a rebuilt
WebView with every fence reinstalled.

A census test pins who touches the flag: the route reads it, the developer row
reads and writes it, and the key itself lives in one module.

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

* test(mobile): type the hoisted test doubles instead of asserting them

The changed-code casting gate refuses `as` in new code, and these three were
only widening an empty literal. An annotated `vi.hoisted` factory does the same
job under a check.

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

* fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress

Found on a simulator with the paired desktop stopped: the client never settles
on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting`
-> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping
`reconnecting` to "still connecting" left a phone holding a verified cached
generation on `checking` forever instead of opening it, which is the one case
the offline rule exists for.

`connecting` and `handshaking` are the first dial and still wait; everything
else is unreachable. The mapping moves next to the reducer it feeds, because it
is a decision and the runner is supposed to hold none.

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

* test(mobile): build the reachability stub instead of asserting it

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

* refactor(mobile): move the shell session vocabulary into its own module

Pure move, no behaviour: the states, events, effects and gates the reducer
and its runner share now sit beside the reducer rather than inside it, so
the transition rules have room to grow under the file's line budget.

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

* fix(mobile): drop a shell effect result whose flow has been superseded

Every restart of the flow bumps a number the effects of that run are stamped
with, and a result echoes it back: a manifest read still in flight when the
socket drops used to reject after the offline path had already opened the
cached generation, replacing a displayed workspace with a download failure,
and a status refetch arriving mid-check used to run the cache read and the
download twice. The gates restart no longer clears the remount latch either;
only the retry button does, so a reconnect cannot grant a second remount.

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

* test(mobile): census the flag across modules, not just src and app

The native view tree was outside the scan, so a reader added there would
have passed an assertion that reads as exhaustive. Proven by adding one to
the shell view module: the census fails.

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

* fix(mobile): let only the state that mounted the view hear the view

A native batch reports two failures in a row, and the reducer applied both:
document-load-failed started the delete-and-refetch, render-process-gone
then made it terminal without a new flow, and the cache read the recovery
had already asked for dragged the session back to checking behind a failure
screen. A report arriving outside `ready` is from a view that is no longer
on screen, so it changes nothing.

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

* fix(mobile): say a host status could not be read instead of spinning on it

A transient status.get failure settles the gate unreadable and nothing probes
it again, so the route sat on "Checking host" for as long as anyone left it
there and Try again re-read the same settled answer. It now says what
happened and offers no retry, and a status that does become readable picks
the flow back up on its own.

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

* fix(mobile): restart the flow on the verdict that changed, not on every gates object

A reconnect cycle rebuilds the gates several times a second with the same
answer in them, and each one re-swept the staging tree and flipped an offline
screen to a spinner and back. Only a changed verdict restarts now, which is
also why the gates effect has to depend on the host id: two hosts whose gates
read identically would otherwise leave the second session in `checking`.

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

* test(mobile): cover what only the shell runner can get wrong

Three cancellations had no test: the epoch that stops a result reaching a
session that is gone, the unmount cleanup that aborts the download, and the
retry that does both before starting over. Each is now red under its own
mutant. The download also re-checks the abort before it writes, since an
abort landing between the fetch's last read and the commit would otherwise
still put a generation on disk for a screen nobody is on.

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

* fix(mobile): write the shell runner's refs after the commit, not during render

React can replay or discard a render, so a handle written during one can run
effects for a session that never existed. The client and the host cache key stop
being refs at all; the effect handle is committed in an effect above every
effect that dispatches.

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

* fix(mobile): keep the hybrid shell flag unreadable outside a development build

Development and release share a bundle id, and the iOS data container survives
an install-over, so a flag a developer toggled on would follow the store build
in and mount the shell on a deep link. The release read never reaches storage.

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

* test(mobile): drive the route's flag read as each build kind reads it

The route test exercises the real preference read, so it has to say which build
it is. A store build whose container kept a development toggle redirects.

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

* fix(mobile): let a cache read that lands mid-dial wait for the compat check

A connection still being made is not a host that cannot be reached. Opening the
cached generation there skips the compat check the landing connection is what
makes answerable, so only `unreachable` takes the offline path now.

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

* fix(mobile): move the developer toggle only after its write lands

The route reads the flag back from storage, so a switch that moved on the tap
let the open button race the value that was being persisted. The switch and the
button both stay put until the write settles, and a failed write keeps the
previous position.

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

* test(mobile): say which build kind a test runs as without asserting on globalThis

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

* fix(mobile): undo a staged generation the abort reached before the commit

The commit is the write staging cannot take back: it renames into the active
slot and moves the host index. An abort landing while the bytes were being
staged now removes the staged tree instead of activating it.

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

* fix(mobile): open the cached workspace when the link, not the bundle, cut a read short

An RPC rejection can reach the reducer before the reachability change does, so
the offline gate never fires and a phone holding a valid generation reads that
the workspace could not be downloaded. A read that failed on the link now opens
what is on disk, the same path offline takes; a verdict about the bundle, from
the host or from the bytes, still fails.

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

* fix(mobile): send a hybrid shell recovery through the same gate a start takes

A view failure deleted the host cache and went straight back to the manifest
check on whatever gates the ready session happened to be holding. Gates that
arrive while a generation is on screen are stored without restarting, so after a
reconnect whose status probe failed a ready session carried statusReadable false
and an empty capability list, and the recovery's manifest check walled the host
as bundle-unavailable: terminal, no retry, about a host that never answered.

The gate is now one verdict both entries read, and recovery passes its delete
through it, so an unreadable status lands on the status-unreadable message that
re-arms when a readable gate arrives, and only a readable refusal still walls.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 08:22:25 -04:00
Neil 7caf7ab1e1 refactor(mobile): split session and terminal surfaces
(cherry picked from commit f129f2926a)
2026-08-31 23:35:08 -07:00
3d341c132f fix(mobile): dismiss the keyboard after sending to an agent (#17059)
* fix(mobile): dismiss the keyboard after sending to an agent

Sending a message left the software keyboard up, covering the reply the
user was waiting on. Drop it once the send is accepted, on all three send
paths: the terminal live input, the buffered command input, and the chat
composer.

Gated on the tab being an agent session. A plain shell keeps the keyboard
so back-to-back commands stay typeable, a rejected send keeps it so the
handed-back draft stays editable, and the accessory shortcut row is
untouched because dismissing would pull away the row being tapped.

* fix(mobile): gate keyboard dismissal on accepted sends

* fix(mobile): fence keyboard dismissal completions

* fix(mobile): fence stale send completions

* test(mobile): update terminal guard expectations

* fix(mobile): restore rejected buffered drafts by origin

* fix(mobile): preserve intentional buffered draft clears

* fix(mobile): harden send dismissal authority

* test(mobile): preserve Strict Mode send dismissal

* fix(mobile): preserve drafts across terminal remints

* fix(mobile): preserve draft ownership through terminal races

* fix(mobile): harden draft recovery and send freshness

* fix(mobile): fence route reuse and native draft clears

* fix(mobile): preserve native draft edits before clear

* test(mobile): pin the terminal-list sweep that bounds buffered drafts

`bufferedTerminalDraftState.pruneDrafts(retainedHandles)` is the only bound on
two structures that live as long as the session screen — the buffered-draft
record and the pending-restoration map — and nothing failed when it was deleted
or when it was pointed at the raw `terminal.list` handles instead of the
retained set. Both mutations reddened 0 of 3,949 mobile tests.

Adds the wiring pin (both mutations now redden it) plus two behavioural tests
showing why the argument matters: `terminal.list` omits a chat-covered handle
while the desktop graph reloads, so the raw list drops a draft the user is
still holding while the retained set keeps it.

---------

Co-authored-by: Merge Sim <merge@sim.local>
Co-authored-by: Merge Sim <sim@local>
2026-08-30 16:59:55 -07:00
Neil 81b4d88bd6 refactor(mobile): split tasks route into focused modules (#17438)
* refactor(mobile): split tasks route into focused modules

* fix(mobile): repair tasks refactor module boundaries

* chore(mobile): document intentional render resets

* fix(mobile): remove stale lint suppressions from tasks split

* test(mobile): keep parity checks stable with doctor suppressions

* test(mobile): follow tasks module split

* test(mobile): follow project routing module split
2026-08-30 14:10:36 -07:00
Neil 2daea491b9 Enable React static component and purity lints (#17337)
* Enable React purity and static component lints

* fix lint follow-up clock activation and eligibility expiry

* test render status bar provider panel with hooks

* fix checks clock activation before paint

* fix React type import in git history files
2026-08-29 23:46:35 -07:00
Neil 8cf692cf71 perf(mobile): slow certified terminal inventory sweeps (#17182)
* perf(mobile): slow certified terminal inventory sweeps

* fix(mobile): recover terminal inventory after stream teardown

* fix(mobile): keep fast sweeps while tabs drop a connected terminal

Tab snapshots are partial and only ever add terminals, so `terminal.list`
is the sole remover. With healthy sweeps slowed to 1/min, a background
terminal closed on the desktop lingered up to 60s, leaking its WebView and
leaving tabStripVisible stale.

Carry `connected`/`orphaned` through TerminalRecord and treat absence of a
connected, non-orphaned handle as a hint to schedule the authority -- never
as a decision to prune. Parked leaves and orphaned PTYs are legitimately
untabbed forever, so excluding them keeps the slow cadence from pinning.
2026-08-29 21:18:28 -07:00
Neil 15abc3fe26 Split mobile host screen layers (#17181)
* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Split settings navigation metadata

* Split daemon initialization lifecycle

* Split GitLab item dialog

* Split relay dispatcher layers

* Split mobile host screen

* Retarget mobile view settings source test

* Fix F3-speech for #17123

* Fix F1-cycle for #17131

* Fix F4-navtest for #17157

* Fix F2-allowlist for #17161
2026-08-29 20:16:28 -07:00
Jinwoo Hong 5c10bf9001 fix(sta-5781): stop cross-client resets of workspace view preferences (#17057) 2026-08-28 15:20:21 -07:00
Jinwoo Hong bf5660df51 feat(mobile): add causal network diagnostics (#16837) 2026-08-27 21:42:23 -07:00
Brennan Benson 4f14d6d757 fix(mobile): stop held accessory keys after release under latency (#14219)
* fix(mobile): backpressure accessory key repeats

* fix(mobile): keep accessory repeats on the pressed terminal

* fix(mobile): serialize accessory key presses

* fix(mobile): preserve queued accessory taps

* fix(mobile): fence queued taps across reconnects

* fix(mobile): recheck queued tap delivery context

* fix(mobile): stop accessory repeats after IME send failure

* fix(mobile): pace repeated live-input edits

* fix(mobile): dispatch accessory taps without ack delay
2026-08-27 19:06:08 -07:00
Neil bdabb09a07 Extract mobile host and browser helpers (#16779) 2026-08-27 17:50:11 -07:00
Brennan Benson fab6e0d6e7 fix(mobile): scope optimistic workspace removal to the deleted host (#15424)
* fix(mobile): scope optimistic workspace removal to the deleted host

A worktreeId repeats across hosts, so filtering the list on the bare id also
removed the identically-named workspace belonging to the other host. Match on
(worktreeId, hostId) through a named helper so the rule is testable.

* fix(mobile): key host worktree rows consistently
2026-08-19 17:12:44 -07:00
Jinwoo Hong bb09dc1749 fix(mobile): escalate a persistently rejected Relay pairing to re-pair (STA-4681) (#15237) 2026-08-17 22:36:54 -07:00
Neil 0e96b82e44 fix(mobile): keep phone tab selection across host snapshots
* fix(mobile): keep phone tab selection across host snapshots

Preserve device-owned tab focus across ordinary host republications while explicit follow navigation remains authoritative. Retire closed selections across clients so stale snapshots cannot resurrect tabs.

* fix(mobile): acknowledge session tab closes

* fix(mobile): avoid tombstones for uncommitted closes

* fix(web): implement session close IPC stubs

* refactor: simplify mobile tab close flow

* fix: bound session tab close confirmation
2026-08-17 18:26:52 -07:00
NeilandBrennan Benson 66b599399f fix(mobile): decide terminal preedit from the marked-text range, not a script table (#15007)
* fix(mobile): decide terminal preedit from the marked-text range, not a script table

The live terminal capture field decided what to withhold from the PTY with a
Unicode-block allowlist (Hangul jamo and syllables) and held exactly one trailing
code point. Kana and kanji are not in the table, so a Japanese reading streamed to
the PTY one fragment at a time and was repaired afterwards with DEL bytes (#7427).

A code-point table cannot work, and the counterexample is not exotic: Chinese
pinyin preedit is plain ASCII, and a Japanese romaji reading is one code point on
the first keystroke and three on the fourth. Preedit is a property of the FIELD,
not of the characters in it, so the only signal that identifies it is the text
system's marked-text range. That is what a reference terminal implementation uses
on every platform it supports - `hasMarkedText` there, the input-method context's
composing state elsewhere - and neither one classifies code points anywhere in the
input path.

So the mirror now takes the marked-text report per change and holds the whole
preedit region, whatever its length or script:
- Subscribe the capture field to `onChange`, not `onChangeText`; only the raw
  native event carries the report at all.
- A reported preedit is held entire and is never committed by the settle timer,
  because preedit is not text yet. Explicit boundaries still flush it.
- `isTerminalLiveHangulCodePoint` and its four ranges are deleted.

iOS reports the range but React Native drops it before JS, so the pinned patch
forwards `markedTextRange` into the change payload. It is three hunks and it
compiles because the app already sets `buildReactNativeFromSource` for iOS. The
same idea was proposed in #11450, which is where the patch comes from.

Android has no marked-text report in React Native at all, and a Kotlin patch would
not help: Android consumes the prebuilt react-android artifact, so node_modules
sources are never compiled. Until the report exists there, the fallback holds the
trailing non-ASCII run. It enumerates nothing, it covers kana, kanji and Hangul,
and ASCII keeps its zero-latency echo - but it cannot see an ASCII preedit, so
Chinese pinyin on Android still leaks its reading. Only a report fixes that.

Not-tested: no physical device or emulator was available, so no real IME drove
this path. Japanese, Chinese and Korean composition are covered at the model and
hook level only, and the iOS patch has not been compiled.

Co-authored-by: Brennan Benson <brennanb2025@users.noreply.github.com>

* fix(mobile): bound the fallback hold to text the pty has not received

The no-report branch walked the trailing non-ASCII run over the whole field and
ignored stableLength, unlike the reported branch directly above it. So after a
settle-timer commit the next keystroke re-held everything already delivered and
the caller erased it with DEL and retyped it — a nine-character Cyrillic word
cost a DEL per already-sent character, and for the 300ms before the re-send the
held text was the only copy, so a blur or reconnect destroyed characters the pty
already had.

Bound it the way the reported branch is bounded. Pinned by a test that drives a
settle commit between every keystroke and asserts no DEL reaches the wire.

---------

Co-authored-by: Brennan Benson <brennanb2025@users.noreply.github.com>
2026-08-17 00:01:24 -07:00
Jinwoo Hong b6d5972ec4 fix(mobile): reland truthful Relay recovery status (#14986) 2026-08-16 19:09:15 -07:00
Brennan Benson c73e5a2f59 fix(mobile): bound pending-handle session-tab recovery polling (STA-4407) (#14916)
* fix(mobile): bound pending terminal recovery polls

* wip(mobile): partial STA-4407 bound pending-handle poll

* fix(mobile): finish bounded pending-handle recovery

* fix(mobile): preserve pending-handle recovery attempts on slow links

* fix(mobile): retain pending-handle cadence budget semantics

* test(mobile): pin pending recovery parked state resets

* chore(mobile): drop the STA-4407 worker status logbook

* fix(mobile): preserve pending recovery liveness

* fix(mobile): coalesce repeated recovery retries

* fix(mobile): memoize pending recovery context

* fix(mobile): type the pending recovery poll test renderer explicitly

* fix(mobile): type the poll test renderer without an any union

* fix(mobile): keep recovery context refs current

* test(mobile): tighten pending recovery coverage

* test(mobile): preserve recovery-source liveness

* fix(mobile): keep the poll test renderer union free of any

* test(mobile): prove parked recovery isolation

* fix(mobile): write the parked-recovery callback ref after commit

* test(mobile): isolate recovery identity changes

* test(mobile): prove recovery publication boundaries
2026-08-16 17:00:49 -07:00
Jinjing 1e63cfef06 Revert "fix(mobile): present pending Relay fallback accurately (#14922)" (#14976)
This reverts commit 3811881410.
2026-08-16 16:30:50 -07:00
Jinwoo Hong 3811881410 fix(mobile): present pending Relay fallback accurately (#14922) 2026-08-16 12:38:26 -07:00
Brennan Benson e570cade3c fix(mobile): keep polling session tabs while an active terminal is pending-handle (STA-4256) (#14623)
A terminal tab published as `status: 'pending-handle'` renders the session
screen's spinner. Leaving it requires a snapshot that carries the materialized
handle, but a certified-live tabs stream parks `poll()` unless
`hasRecoveryNeed()` says otherwise — and that predicate never considered a
pending terminal. A host that mints the handle without republishing therefore
stranded the pane on its spinner forever: measured live, zero further
`session.tabs.list` calls over 90s while `terminal.list` kept firing every 2s.

Mirrors the existing native-chat recovery-need pattern. Client-only; no wire
change.
2026-08-14 19:20:05 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00
Jinwoo Hong 54aa22b2df fix(mobile): self-heal host opens and harden session liveness (#14333) 2026-08-13 17:15:03 -07:00
Neil 4882eeb8ac rm git shim: neutralize stale wrappers without a host gate (#14255)
* Revert "fix terminal attribution shim removal edge cases (#14187)"

This reverts 585dd6d3a9. Re-landed in the next commit without the host capability gate. Nothing shipped with it, so no migration constraint.

* rm git shim: neutralize stale wrappers without a host gate

Re-lands the cleanup half of #14187: pass-through tombstones for retained wrapper paths, env/PATH scrubbing at every spawn owner, and the retired setting drop.

Only writes tombstones when the legacy directory already exists, so a clean install no longer has it created. Leaves out the terminal.attribution-removed.v1 capability gate: the tombstone neutralizes each host locally, so refusing terminal create/split against older hosts denied service without adding cleanup.

* rm git shim: surface neutralization failures and fix rollback marker

Readiness review follow-ups: warn on each failed attempt and on give-up (was silent and undiagnosable); write a VERSION marker distinct from the retired shim's '7' so a rolled-back build rewrites its own wrappers; clear a captured ORCA_REAL_* path that no longer exists so the cmd wrapper's where.exe fallback can run; stop a locked temp file masking the real error. Adds retry-exhaustion coverage.

* rm git shim: pin the cmd fallback order and correct the give-up count

Round-2 review follow-ups: string-pin that a stale ORCA_REAL_* is cleared before the where.exe fallback, and count the initial attempt in the give-up warning so it agrees with the per-attempt line.

* rm git shim: keep the split-failure toast

The revert took a toast that #14187 added alongside the gate but which stands on its own: without it a failed remote split only reaches the console and the pane silently never appears. Also pins attempt ordinals in the retry-exhaustion test.
2026-08-13 03:01:45 -07:00
Brennan Benson 0ed6db77cf fix(mobile): open agent-cited external chat files (#14166)
* fix(mobile): open agent-cited external chat files

* fix(mobile): keep cited external files read-only

* refactor(mobile): derive cited-file mode from provenance

* fix(mobile): accept sentence-final cited paths

* fix(mobile): preserve cited SSH grant scope

* refactor(file-links): share location suffix parsing
2026-08-13 01:12:19 -07:00
Brennan Benson 6b84e33251 fix(mobile): trade a lease-only stream for output when leaving a chat tab (#14179)
* fix(mobile): trade a lease-only stream for output when leaving a chat tab

Tapping a terminal tab from a native-chat tab left the terminal blank. The
route subscribes the incoming handle synchronously in switchTab, while the
coverage it reads still describes the chat tab being left, so the handle gets
a `mobileInputLeaseOnly` subscribe — the host answers `subscribed` and nothing
else, no scrollback and no data frames. Input kept working because it rides a
separate terminal.send RPC.

The reconciler then cleared its covered marker (the active handle changed), so
the stream was active and uncovered — which its state machine could not tell
apart from a healthy one, because `streamActive` conflated the two. It settled
on 'none' and nothing else repaired it: the route's web-ready path bails on any
live subscription. The tab stayed blank until app restart.

Track which handles hold a lease-only subscribe and thread it into the
reconciler as `streamIsLeaseOnly`, so an uncovered handle holding one resumes
into a full stream. The covered branch is untouched, so the input lease that
keeps the chat composer from locking forever (#10681) still survives.

* fix(mobile): clarify stream reconciliation ownership

* fix(mobile): keep stream reconciliation checks clean
2026-08-13 00:07:28 -07:00
Neil 585dd6d3a9 fix terminal attribution shim removal edge cases (#14187)
* fix(terminal): fully retire attribution shim

* fix(terminal): harden shim tombstone path lookup
2026-08-12 23:22:48 -07:00
Jinjing 2b1e69d4b0 Add keyboard dismissal for mobile markdown editor (#13856)
* fix(mobile-markdown): enable keyboard dismissal while editing

Allow users to dismiss the soft keyboard while composing markdown content. Extract the MarkdownReader component into its own file and add WebView-based caret preservation to restore the cursor position after the keyboard closes. This prevents the editor from losing focus and erasing the user's selected caret location when the keyboard hides.

* improve test
2026-08-11 13:08:50 -07:00
Neil 9796f7dc5f refactor(mobile): use shared GitHub Project sorting (#13459) 2026-08-10 20:41:34 -07:00
Neil faaf3d2588 refactor(mobile): use shared diff and OSC implementations (#13433) 2026-08-09 19:16:26 -07:00
Jinwoo Hong 427f178a75 fix(mobile): load the repo list in GitHub Project mode (#12972) 2026-08-09 14:55:31 -07:00
NeilandOrca 17cfc968cf Revert the terminal IME composition-ownership change (#13282)
* Revert "test(ime): restore coverage the composition-ownership change removed (#13168)"

This reverts commit 25a8c517e1.

* Revert "refactor(terminal): return IME composition ownership to xterm (#13128)"

This reverts commit 17b3dff3c4.

* test(ime): keep the architecture-neutral Korean trace coverage

The recorded IBus/fcitx5 and Windows MS-Korean traces from #13168 assert PTY
byte order, not composition ownership, so they still hold once the terminal
composition layer is restored. The mobile accessory-order test pinned the new
handleLiveInputChange signature and does not.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): keep the macOS Backslash bypass through the revert

The restored native-text forwarder only claims keys for input sources in its
hardcoded CJK allowlist, so third-party IMEs off that list (Qingg, #10896) still
get a raw backslash. #13128 added this bypass as a partial replacement; keep it
rather than trade the open issue back.

Scoped to the bare backslash key. The rest of shouldBypassXtermForMacNativeText
bypassed all unmodified non-ASCII text, which would race the restored forwarder.

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): move the mirror-step ref write out of render

The restored hook assigned runMirrorStepRef during render, which is not
replay-safe — React can discard render work, so the mutation can leak from UI
that never commits. Its only read is inside the held-commit timer, which fires
long after commit, and the ref has a safe default, so an effect is soon enough.

Surfaced by the changed-lines React Doctor gate: the rule postdates this code,
so restoring the file re-introduced it as a new violation.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-08 19:06:50 -07:00
NeilandOrca 17b3dff3c4 refactor(terminal): return IME composition ownership to xterm (#13128)
* fix(terminal): return IME composition ownership to xterm

* fix(mobile): derive terminal input from native replacement ranges

* test(mobile): record iOS Japanese IME traces

* fix(mobile): preserve native IME replacement ranges

* fix(xterm): flush queued application input after IME commit

* test(terminal): pin Korean intermediate commit

* test: pin Windows IME shortcut ownership

* test: replay IBus number candidate commit

* fix: preserve native macOS input-method punctuation

* refactor(terminal): remove stale mac focus override

* fix(mobile): preserve soft keyboard deletion ranges

* fix: keep IME-owned palette chords in renderer

* fix: stop carried IME shortcuts at renderer owner

* fix: preserve carried IME shortcut dispatch

* fix: narrow main-owned shortcut actions

* test(mobile): pin Japanese IME replacement traces

* test(terminal): retain paired native IME trace

* fix(chat): preserve browser IME composition ownership

* fix(chat): retain macOS IME confirm gesture

* fix(chat): expire unmatched IME confirm carry

* fix(chat): isolate IME confirmation expiry

* fix(chat): retain active IME confirmation

* refactor(terminal): remove dead composition handler

* feat(ime): add shared Enter-ownership seams for CJK composition

The confirming Enter of a CJK composition arrives as two keydowns and the
orderings differ by platform: Windows/Linux redispatch the unmarked Enter/13
before keyup, macOS delivers keyup first. A guard reading only isComposing or
keyCode 229 misses the redispatch, so surfaces submitted on a confirm.

Adds useImeEnterGestureOwnership (carry token, next-frame expiry), a shared
ImeEnterGuardedForm for native implicit submission, and the cmdk seam covering
18 CommandInput surfaces at one site.

A chorded Enter arms the carry but is never swallowed — the reverse would eat a
user's deliberate Cmd/Ctrl+Enter. Both failure modes are pinned by
ime-enter-gesture-ownership-contract.test.ts.

Co-authored-by: Orca <help@stably.ai>

* refactor(terminal): consolidate native input listeners and parked-screen owner

Extracts the shared native-input listener installer and renames the parked-screen
detector for what it actually does, replacing per-call-site duplication. The
listener installer keeps a forgetOptionKeyLocationOnBlur flag so per-window
semantics are preserved rather than flattened.

Net deletion; no behaviour change intended.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): pin recorded IME shapes as regression tests

Nine regression tests built from hashed affected-platform captures, each with a
paired ordinary negative and a discriminating mutation verified to take the file
from all-passing to exactly one failure.

Covers the Windows MS-Korean Shift family (#12179, #11878, #12151, #11946,
#12152) and the Korean TUI line-break rows (STA-3237, STA-3222, STA-3129).

STA-3237 pins the empirical 3-Shift / 2-active-composition / 2-newline ratio the
device run established — the third Shift produces nothing because Space has
already committed. That ratio is not derivable from a static capture.

Co-authored-by: Orca <help@stably.ai>

* fix(ime): guard Enter-commit surfaces against CJK confirm

Applies the Enter-ownership guards across the surfaces whose Enter commits
something: publishes, clones, pairs, installs, posts, or persists.

Tiered deliberately rather than uniformly. Irreversible and remote-effect sites
take the carry token, which also blocks the unmarked redispatch. Locally
reversible sites take the oracle check with a one-line comment naming the
residual, because a spurious commit there costs one undo.

Three numeric fields are left unguarded with the reason in-code: Chromium blanks
number inputs at compositionstart, so a confirm-Enter only ever reaches an
empty-draft reset. Measured with a CDP probe rather than assumed — a guard that
cannot fire is noise.

Co-authored-by: Orca <help@stably.ai>

* test(ime): teeth-check the Enter guards on every guarded surface

One suite per guarded surface, each verified by deleting the guard and
confirming the test fails. A green guard test without that check is unverified,
not verified.

Two shapes pass vacuously in happy-dom and are avoided here: native implicit
form submission never fires, and blur() is inert on an unfocused element. Both
made "the commit did not happen" assertions pass with the guard removed, so the
suites assert the guard's contract directly instead.

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): keep iOS Korean commits whole through the live-input path

iOS Korean reports isComposing: false on every event, so it bypasses the
composition guard entirely. The strict owner rejected UIKit's transformed
post-change field and sent only the leading jamo — the reported symptom.

Prefers the authoritative same-event field text over the predicted text when the
supplied operation cannot produce it. Generic: no Korean special-case, no locale
classifier, no normalization. Adds the RN-target-keyed submit carry alongside it.

Co-authored-by: Orca <help@stably.ai>

* test(e2e): make IME capture harnesses fail loudly instead of silently

Four instruments recorded silence as success, so a void run scored as a clean
one:

- readTerminalImeBoundaryTrace returned an empty trace when the probe never
  installed, making every "nothing leaked" negative pass vacuously
- summarizeLatencies([]) returned a perfect zero distribution that passed all
  three latency thresholds
- the macOS Vietnamese spec pinned an input-source ID that does not exist, and
  failed as though the operator had chosen the wrong source
- the expectedLineCount=1 prefix property was undocumented and one edit from
  silently downgrading a PTY assertion

Input sources now resolve by enumeration and name the near-matches on failure.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): cover Cangjie cancellation and fix a cross-namespace assertion

Adds #11951's recorded Cangjie cancel shape to the existing cancellation suite,
which covered Pinyin and Sogou but not Cangjie. One keystroke then Backspace
arriving as deleteContentBackward with data: null, so the stale preedit is the
only thing a fallback could replay.

Verified against the historical pre-6cd944c62b3 bundle: the positive fails with
['尸'] where [] is expected, while the ordinary negative stays green.

Also fixes the Vietnamese spec, which asserted a TIS-space input-source ID
against getKeyboardInputSourceId(). Those two Orca APIs report the same source
in different namespaces — TIS nests it under VietnameseIM, the app API does not.
The resolver stays as an installation precondition; the assertion matches the
leaf.

Co-authored-by: Orca <help@stably.ai>

* test(e2e): add a real-IME macOS arm for the Korean chord commit

The existing korean-ime-terminal-shift-enter-commit spec synthesizes composition
over CDP: Input.imeSetComposition sets the preedit directly and Input.insertText
performs the commit. Asserting the IME produced events you injected yourself is
circular, so that spec cannot certify real-IME behaviour.

This arm selects 2-Set Korean via TIS, reads it back live, and injects through
System Events key codes, so the OS owns the preedit, the commit instant, and
isComposing. PTY byte expectations are preserved verbatim.

Covers 2 of the original 4 cases by design. The other two are the Windows/Linux
redispatch-before-keyup ordering, which macOS cannot produce and which cannot be
selected -- the OS decides it. Reintroducing synthesis to "restore coverage"
would reintroduce the circularity.

Co-authored-by: Orca <help@stably.ai>

* test(e2e): assert the macOS chord arm at the PTY boundary, not the renderer

The byte expectations were transcribed from korean-ime-terminal-shift-enter-commit
:364/:383, which assert against onData -- a renderer boundary where the terminator
is CR. This spec reads the PTY child, where the tty has already converted CR to LF.

Names both forms per row rather than swapping the constant, so the conversion reads
as evidence that the capture reached past the renderer, as #11936 and #11951 record.
Ctrl+Enter's CSI-u sequence is unaffected and is identical at both boundaries.

Co-authored-by: Orca <help@stably.ai>

* test(e2e): measure composer-to-onData latency and stop dropping IME keystrokes

Two defects in the echo latency probe.

It hooked onWriteParsed and onRender but never onData, so it measured
key->parse->render echo rather than the composer-vs-onData delta the latency rows
need. Adds a third hook feeding its own sample set.

And `event.key.length !== 1` silently dropped IME keystrokes: Pinyin and Cangjie
keydowns arrive as key:'Process' (length 7). Replayed over the captured corpus,
the old filter accepted 580 of 4137 Chinese IME keydowns -- it was discarding 80%
of them. The new filter matches the shape the owner itself branches on.

Attribution charges each onData to the latest keydown rather than a FIFO head,
because composing jamo emit no onData at all and a queue would credit a whole
composition to its first keystroke. The consumer now asserts sample count before
any percentile, so a zero-sample run cannot render as a flawless distribution.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): pin the WSL shifted-jamo newline shape for #11919

In Korean 2-set, Shift types ordinary letters -- the double consonants and the
compound vowels. Each such keystroke reaches Chromium as key='Process',
keyCode=229, shiftKey=true.

The v1.4.163 classifier matched exactly that pattern with no code guard, so it
called those keystrokes Enter, rewrote them to a synthetic Shift+Enter, and
injected a newline into the middle of the word -- with no Enter key pressed.
That is why the reporters said "no modifier key pressed": they had not chorded
Shift+Enter, but they had pressed Shift, to type the double consonant.

Asserts the row's own recorded capture: 40 immediate keydowns, exactly 3 of them
Shift-carrying inside a single syllable, and an onData stream with one newline
per Enter press and none mid-word. Two ordinary negatives keep it from being a
blanket mute -- the same session's non-IME keydowns still reach shortcut policy,
and an ordinary Shift+Enter still resolves through the real policy.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): pin the composition commit lag that made Korean type one behind

macOS Korean 2-Set commits syllable N only when the first jamo of N+1 arrives, so
compositionend and compositionstart land in the same task. A composition-start
handler cancelled the pending finalizer that was the only path to triggerDataEvent
and ended the session without emitting bytes, so every committed syllable reached
onData exactly one syllable late and the backlog cleared only at a Space or Enter.

Types continuously with no Enter and no Space -- either would flush the backlog and
hide it -- and samples onData at every syllable boundary. Paired with a
length-matched ASCII arm that stays green throughout, so the positive is a fact
about composition rather than about timing in general.

Bisected to a single call site across five builds: pristine, 1.4.155 and 1.4.162
pass, 1.4.163 fails, removing the one call repairs it, restoring it fails
identically. That window is exactly the reporter's "started immediately after
updating".

Co-authored-by: Orca <help@stably.ai>

* test(mobile): cover the send-queue abort that silently drops queued keystrokes

One failed send in use-terminal-live-input-commit aborts every keystroke
queued behind it, with the error swallowed by .catch(() => false). The
existing test resolves(true) on every send, so the failure branch was
uncovered.

Four arms: the abort itself, an ordinary negative on the healthy path, a
throwing sender, and a liveness control proving the queue recovers once
the chain settles. Deleting the abort takes 4 passed to 3 failed, with the
ordinary negative correctly surviving.

Scope is stated in the docblock: this is a transport send-queue abort,
reachable only via a real disconnect or RPC error. REQUEST_TIMEOUT_MS is
30s, so latency alone cannot reach the branch — consistent with #7094's
symptom class, not proven to be its cause.

* test(terminal): pin that daemon snapshot/restore cannot disturb a composition

Two independent reporters attributed broken Korean composition to the
always-on PTY daemon repainting terminal state over the preedit. The
attribution is wrong on ancestry — the daemon shipped three months before
the version both call good — but the boundary was never actually tested.

Runs the real applyMainBufferSnapshot choreography against a live
composition, including the full 2J/3J/H wipe plus the resize and
alt-screen branches. textarea.value, selectionStart/End,
compositionView.textContent and .active all survive byte-identical, and
interleaving a restore between every jamo of 문제 still commits 문제 at
onData. Also pins that the uncommitted preedit is absent from the captured
snapshot: it lives in the textarea, never the buffer, so a restore has
nothing stale to echo back.

Injecting one textarea.value = '' into the restore fails exactly the three
restore-boundary tests.

* test(terminal): pin that Cmd tears down a composition where Ctrl and Shift do not

xterm's composition keydown exempts only keyCode 16/17/18 (Shift/Ctrl/Alt)
plus 20/229. macOS Meta — 91/93/224 — is absent, so a Cmd press mid-composition
takes _finalizeComposition(false): the overlay goes dark and never recovers,
because compositionstart is not re-fired. The user composes the rest of the
word blind. Linux and Windows users press Ctrl and are exempt.

xterm already has a Meta-aware modifier predicate in wasModifierKeyOnlyEvent,
so this is an internal inconsistency rather than a deliberate choice.

Owns no reported row and is version-neutral: 5/5 on both 1.4.162 and 1.4.163.
The branch is unexercised in all 328 recorded traces, so this is a hazard pin,
not a regression guard. Only the teardown is asserted; the likely duplicated
commit needs a compositionend the IME kept alive across the Cmd, which no
capture contains.

Deleting the exemption fails exactly the three paired negatives; adding Meta
to it fails exactly the two Cmd arms.

* test(native-chat): characterize preedit loss when a question card replaces the composer

An AskUserQuestion card fully replaces the composer by design, but the
in-flight composition goes with it: the composer unmounts before
compositionend reaches it, so the preedit is never committed to the draft.
The committed text survives only because the draft is cached and restored
via defaultValue. Node identity changes, value 'abc' is preserved, the 가
is gone.

Drives the real NativeChatView -> SessionGate -> InteractiveCard ->
questionActive swap -> Composer -> ComposerField, flipped by writing the
same store field an AskUserQuestion hook event writes. Flipping
questionActive to false fails exactly this test and nothing else across
639 native-chat tests, so the path was entirely unguarded.

CHARACTERIZATION TEST: it asserts the loss. Fixing the defect — committing
the preedit before the swap, or keeping the composer mounted — will make
this file fail. Update the expectations to the new contract rather than
working around them.

Owns no reported row. #12118/STA-3219 flicker is keyed to token counters,
which provably do not remount, and a question card arrives once per
question.

* test(terminal): pin the duplicated commit when Meta interrupts a composition

_finalizeComposition(false) sends textarea.value.substring(start, end) but
cannot clear the IME-owned textarea, so a later compositionend re-sends the
same range. Meta reaches that path because CompositionHelper exempts only
Shift/Ctrl/Alt; xterm's own wasModifierKeyOnlyEvent covers Meta four ways,
so the omission is an internal inconsistency rather than a choice.

Companion to the modifier-exemption guard, which deliberately pins only the
overlay teardown. This pins the data consequence.

HAZARD PIN: owns no reported row. The trigger is unverified on hardware —
no capture in the corpus contains a Meta-during-composition gesture, and
whether macOS keeps the composition alive across it is unmeasured. The
duplication follows from the code given that sequence; whether users reach
the sequence is the open half.

An earlier premise that Space (keyCode 32) reaches this path was refuted by
a corpus scan: 0 of 731 evidence files carry a keyCode-32 Space while
composing, against 171 at 229, and 229 returns early.

* test(terminal): characterize the syllable lost when the textarea blurs mid-composition

CoreBrowserTerminal._handleTextAreaBlur clears the helper textarea
unconditionally — "Text can safely be removed on blur" — while
CompositionHelper._finalizeComposition reads the committed text back out of
that same value from a deferred timeout. By the time it runs the value is
empty, the substring is '', and triggerDataEvent never sees the syllable.
xterm checks composition state in _syncTextArea and omits the same check
here.

Six cases. Blurring mid-composition loses the syllable in every ordering,
including compositionend-before-blur, which is Chromium's real order — so
it is not an ordering artifact. A bare textarea.blur() with no Orca code
loses it too, which places the owner upstream: Orca's unguarded release on
outside pointerdown is one trigger, not the cause. Committing 한 then
blurring mid-가 yields ['한'] where ['한','가'] is correct: one syllable
gone, surrounding text intact.

Teeth checked by inverting — adding an Orca-side composition guard flips
exactly the three cases that route through the release path and leaves the
bare-blur and no-blur cases green, which is the scope split: a fix in
regular-terminal-focus-ownership alone would not close this.

HAZARD PIN, but unlike the others this one has a real production injector —
clicking outside the terminal mid-composition. Owns no reported row. The
shape matches #9738's report; the injector does not, and a shape match with
a mismatched injector is not an owner.

* test(terminal): say which arm the STA-3237 fixture came from

The recorded keydowns are wave 4's A-shift-unmarked-only — the arm that
emits no PTY bytes. Nothing in the file said so, so two readers concluded
the row's events fail the owner's predicate and that STA-3237 and STA-3222
were different defects. They share an owner; the arm that fires is
Process/229+Shift, absent from this bubble-phase trace because the owner
claims it in the capture phase.

Also corrects "code-blind": the v1.4.163 policy emits \x1b\r only for a
shift-only key:'Enter', and a jamo keydown reaches that branch solely via
the isTerminalImeProcessEnter rewrite. The mock is deliberately wider so
the ownership guard stays under test if that rewrite moves.

Comments only — no assertion, fixture value, or mock behaviour changed.

* test(e2e): track the input-source selector the macOS specs shell out to

Five tracked macOS IME specs ran `swift .tmp/select-input-source.swift`, a
file that is gitignored and existed only on one machine. Anyone else
checking out the repo — or the same machine after .tmp is cleaned — could
not run them, and they are the capture drivers for the macOS rows that are
blocked waiting for exactly those runs.

Moves it to tests/e2e/ beside its callers. The chord spec now resolves it
from __dirname rather than reaching two levels up into .tmp.

* test(terminal): pin the CJK repaint decision against the reporter's own output

#12164 comment 1 and #5921 report agent output with double-width glyphs
rendering duplicated character-by-character while ASCII in the same line
stays clean. No IME, no composition, no keystroke — the user never types
the CJK.

Segmenting all three verbatim samples into maximal same-risk-class runs
gives 33 runs and zero violations of "this run is corrupted iff the
production detector flags it": 17 wide runs all corrupted, 16 narrow runs
all byte-identical. The paired negative is co-located in the same line
rather than in a separate run — the reporter supplied it without knowing.

Doubling is asserted as present, not uniform: 자바스크립트 and 시스템 each
leave a jamo undoubled, which is a repaint-region boundary artifact rather
than a per-character transform.

The discriminating arm is in the test rather than a source mutation:
be3f30e2f8 (#6890) elects a repaint for all 17 corrupted runs when the
agent types nothing, and reverting its disjunct elects none. Both
predicates agree once the user has recently typed, which is the pre-#6890
condition.

Samples inlined with per-sample SHA-256 because .tmp is gitignored and
cannot back a landed test.

* test(terminal): pin macOS period substitution landing after the composition

#11504's reporter published a DOM trace showing insertText ". " arriving
149ms after compositionend, when two spaces are typed with a CJK input
source and NSAutomaticPeriodSubstitutionEnabled is on. This replays that
trace against a real Terminal and asserts what reaches onData — bytes to
the PTY, not anything visual.

The owner is stock upstream CoreBrowserTerminal._inputEvent, not an Orca
module, confirmed at the resolved install and in the shipped bundle Vite
loads rather than in the TypeScript source.

Three mutations against that install, predictions written before the runs,
each failing exactly the arms predicted: dropping the composed/keyDownSeen
guard fails two, dropping Orca's intercept fails the one arm where the
payload arrives before the send window drains, and flipping || to && —
the candidate-fix shape — fails the arm that pins the defect itself.

CHARACTERIZATION: arm 1 asserts the broken behaviour and will fail the
moment #11504 is fixed. Update it to the new contract rather than working
around it.

composed is absent from every recorded bundle, so composed: true is the
spec-required value rather than a captured one; the test asserts it before
dispatching so a harness that dropped the field fails loudly.

* test(terminal): replay the recorded Windows Shift sessions through the IME guard

STA-3179 reports a Shift release sending Enter; #12171 reports delayed
Hangul plus doubled newlines. Both replay their own recorded Windows
MS-Korean keydowns through resolveTerminalKeyboardShortcutAction with the
shortcut policy mocked, so the assertions are about which events reach the
policy and what reaches terminal input.

STA-3179's held-Shift gesture yields exactly one newline, from the unmarked
Enter alone; its release arms nothing for the next composition, asserted
after a precondition check that the release really is keyups with shiftKey
already dropped; and an ordinary Shift press-and-release still routes every
keydown, which is the paired non-IME negative.

Teeth, verified by mutation: bypassing the isImeOwnedKeyboardEvent guard in
keyboard-handlers takes STA-3179 from 3 passed to 2 failed / 1 passed — the
survivor being the ordinary-session negative, which is correct, since a
non-IME session should not depend on that guard — and #12171 from 2 passed
to 2 failed. Source restored byte-identical.

Recorded shapes are inlined and the bundles cited in comments; nothing is
imported from .tmp, which is gitignored.

* test(native-chat): correct 61977d4517 — the preedit survives the question card

61977d4517 claimed a composed syllable vanishes silently when an
AskUserQuestion card replaces the composer, and characterized that loss.
The claim was false. Its premise was an artifact of the harness: the test
simulated a preedit with a silent textarea.value assignment and no input
event, which no IME does.

Real composition fires input with insertCompositionText on every keystroke
— the shape this repo already records in its own observed-event capture —
and React's change handler returns on input/change with no composition
gate, so onChange runs for each frame. The draft cache is written
synchronously inside the updater, so the preedit is already committed
before the card can arrive. Driven that way, it survives.

Renamed to match the contract that actually holds, and extended: Hangul
jamo-per-frame, Japanese kana accumulation followed by per-segment
conversion asserting the candidate the user was looking at survives, and a
pin on the mechanism itself — the draft cache holds the preedit while the
card is up.

Teeth: there is no fix to revert, so the mutation is the plausible wrong
one — gating onChange on isComposing(). That takes 4 passed to 3 failed,
with the English negative correctly surviving, since it has no composition
to gate.

Two consequences remain, recorded rather than fixed: the OS aborts the
composition when the field disappears, so a lone jamo returns as a
compatibility jamo the user cannot compose onto, and the remounted
composer is unfocused because the card owned focus.

* docs(native-chat): name the corrected commit and the degraded-jamo consequence

Records in the file itself that 61977d4517 is pushed and wrong, quoting
the two claims that are false, so a reader who finds it in git log reaches
the correction from the file that replaced it.

Also states the residual as a consequence rather than a curiosity: a lone
leading jamo returns as a standalone compatibility jamo (U+3131), which is
not a composable state — the user cannot resume the syllable, only delete
and retype. Preserved, but degraded into something unusable. That is the
note to find if a reporter ever describes exactly that.

The invariant these tests pin is not "the composer commits on unmount" but
"composition input events must reach React" — which is what a future IME
change would break, and is not visible from the swap site at all.

* test(terminal): replay the recorded macOS Telex commit boundaries

#6905 reports Vietnamese composed characters breaking in the terminal.
Replays the retained macOS built-in Simple Telex capture — recorded
selection and value set before each dispatch, since that is what the
commit range reads — and asserts what reaches onData: the first commit
alone, then through the real Enter, then the ASCII tail of the same run.
A code-point count would catch NFD normalisation.

ENGINE CAVEAT, stated first in the docblock: this is macOS built-in Simple
Telex, Telex only. The reporter's three named engines cannot run on the
platform they declared, and which macOS Vietnamese engine they used is
unconfirmed. This file certifies no engine, and does not imply VNI.

The owner is upstream's — CompositionHelper._finalizeComposition's
waitForPropagation branch — so the arms are copies under .tmp aliased by a
scratch config, with node_modules verified unchanged by shasum after every
run. Collapsing the range end onto its start fails all three; collapsing
the start to zero re-emits the first word into the second commit, which is
the reporter's "duplicated" direction. Different failure sets, so the
mutants are distinguishable rather than merely detectable, and the ASCII
assertion passes under both.

Falsifiability here is by mutation, not by a defective build: #6905 does
not reproduce at HEAD, so this has never been watched going red on a real
reproduction.

* docs(terminal): lead the #6905 test with its engine caveat

Comment-only. Moves the caveat above the source line so a reader meets what
the file does NOT establish before what it does — the capture is macOS
built-in Simple Telex, the reporter's named engines cannot run on the
platform they declared, and which engine they used is what gates this row.

Co-authored-by: Orca <help@stably.ai>

* docs(terminal): record that the swallow eats a keystroke after Japanese conversion

This pin framed the swallowed insertText around Cmd interrupting a
composition. A differential through Japanese multi-segment conversion shows
it is broader: type a segment, convert, then press `a`, and the `a` is
lost. No modifier, no exotic gesture. Korean surfaced it first only because
2-Set composes on nearly every keystroke.

Also records why it cannot simply be fixed. The suppression de-duplicates
IMEs that deliver their commit a task after compositionend, which a sibling
test pins; this swallow is that dedup's false positive, and the two events
differ only in payload, so no flag-timing change separates them. Both a
smaller redesign and a content-aware variant were built and measured — the
first duplicates on IBus, the second costs a reported row's test and is
blocked while the patch cannot be regenerated.

The Japanese arrays behind this are authored, not observed: no Japanese DOM
composition trace exists in the corpus.

* docs(terminal): a Japanese capture does exist — correcting dbeecb11be

That commit said no Japanese DOM composition trace exists in the corpus.
False. One does, filed under the Linux bundles rather than the bundle named
for Japanese: 30 DOM events, two にほんご->日本語 conversions, with full
selection state per event. It is retained byte-identically in three further
bundles — one capture copied four times, not four observations, checked by
hash rather than by counting files.

The claim came from checking the bundle named for Japanese, finding nothing,
and generalising to the corpus without querying the rest of it.

Replaying it emits 日本語日本語 under both sequencing extremes on all four
arms, matching its own recorded onData. So "repeated conversion is
undisturbed" is now captured rather than authored. It carries no
post-compositionend insertText, so it cannot speak to the swallow: the
a-after-conversion figure stays authored and unobserved.

Also rewords the paragraph opener. It claimed to broaden a Cmd framing, but
hazard 2 was never Cmd-framed — the lines above already say Cmd does not
reach it. The real gap was that hazard 2 named no trigger at all, which
reads as exotic when it is ordinary.

* build(xterm): land the patch regeneration harness

The five dependency patches under config/patches/ shipped with no tracked
way to regenerate any of them. The xterm one is the hard case: it is derived
from an upstream build, so no fix could be made without rebuilding, and the
tooling to rebuild lived only in one machine's scratch directory. That
blocked a measured fix for a live keystroke-loss bug, and the EditContext
reduction an OSS survey identified as the only real one available.

Adds the regenerator, the upstream pin, the hand-written source patch the
bundle hunks derive from, tests, docs, and a PR job that verifies the
shipped patches still match the pinned build. The job caches the shallow
clone keyed on the manifest, so a cold run is minutes and a warm one under
one. Round-trip verified: regenerating from a clean checkout reproduces the
shipped patch byte-for-byte.

Marks the emitted patch -diff -text. pnpm hashes it byte-for-byte, so a
CRLF checkout would break install on Windows, and its minified bundle lines
make a diff nobody can read — review the source patch instead.

Also rejects unknown flags. --check was the fallback for any unrecognised
argument, so a typo, or --help, silently triggered a full upstream build
instead of what the caller asked for.

* fix(xterm): stop swallowing a keystroke typed after an IME commit

Type a Japanese segment, convert it, then press a key one macrotask later
and that key was lost. No modifier, nothing exotic — every user who keeps
typing straight after converting. Korean surfaced it first only because
2-Set composes on nearly every keystroke.

handleCompositionInput discarded the payload unconditionally in the window
after the deferred send: _isSendingComposition stays true for one macrotask
after the timer cleared _pendingCompositionStart, and the branch substituted
'' for whatever arrived. The suppression is not itself wrong — it
de-duplicates IMEs that deliver their commit an event-loop turn late, which
terminal-stock-composition.test.ts pins. It just could not tell a duplicate
from new input, because the two events are identical apart from payload.

Now it compares against _sentComposition, the text the deferred send
actually emitted, and discards only a match. A flag-timing redesign was
measured first and rejected: it fixed this and duplicated on IBus, because
no timing change can separate events that differ only in content.

Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and the lockfile hash are derived, not hand-written.

The commit-overlap pin's swallow arm now asserts the repaired contract —
the value its own comment already named as correct and as what stock
beta.287 emits. #11504's arm at :184 flips too; it never covered that
report, as its own prior note recorded, and the reporter's +149ms arm is
untouched and still asserting the defect. Provenance hashes in three test
docblocks are updated, since regenerating changes the patch hash and with it
the resolved install directory.

* docs(terminal): re-measure the #6905 mutation citations against the new bundle

Regenerating the patch moved the resolved install, so this docblock's
patch_hash, line count, two line numbers and three mutation outcomes all
described a bundle that no longer exists. The deferred branch is one the
fix writes into, so the outcomes could not be re-pointed on reasoning.

Line numbers read off both files by diffing anchors rather than derived by
arithmetic: 201 to 205, 159 to 163. Outcomes re-run through the retained
rig, which re-resolves through the module loader and re-derives each arm
from a unique minified anchor: pristine 3 passed, m1 3 failed, m2 2 failed,
m3 3 passed — identical to the old bundle. Guard controls in both
directions exit 1, so the counts are falsifiable.

Comment-only; the assertions and expectations are unchanged.

* fix(xterm): size the preedit overlay to the cells its text will occupy

updateCompositionElements computed the overlay's left edge from the grid
but never its width, so the preedit rendered at the font's natural advance
while the committed text takes two cells per wide glyph. Measured in
Chromium 150: 가나다라 drew 48.45px as a preedit and 69.20px once committed
— the same characters, same font, 30% narrower, and drifting further with
each syllable. Every macOS mono font carrying Hangul measured 0.49–0.72 of
two cells; never 1.0.

Deriving the width from wcwidth and the cell measure moves Korean, Japanese
and Chinese to 1.000 and leaves ASCII at 1.000, which it already was:

  한        12.125 -> 17.297   (17.30 expected)
  가나다라   48.453 -> 69.188   (69.20)
  안녕하세요 60.563 -> 86.500   (86.50)
  日本語     42.000 -> 51.906   (51.90)
  abcdefgh  69.234 -> 69.203   (69.20, unchanged)

Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and lockfile hash are derived rather than hand-written.

The unit test asserts the arithmetic, which is what CI can run. The pixel
consequence was measured on macOS with SF Mono in an Electron harness, not
on the Windows font stack STA-3232 reports from — so this demonstrates the
mechanism and does not stand as that row's platform evidence.

* test(e2e): pin the macOS Korean preedit as visible only while composing

#11914 reports the composing text invisible until Space. Its c3 was recorded
as unobtainable, and the reason on file was wrong: the boundary IS
assertable, but not in happy-dom, which reports display:block in BOTH the
active and inactive states and zeros for every rect. A test there passes
with the defect present.

Captured on real hardware instead: hidden and 0x0 before, .active with
display:block, a 15.84x16 rect and checkVisibility() true while composing
그, hidden again after. 39 DOM events, 2 composition starts, onData
["한","그","\r"].

Two mechanism findings are carried in the setup because both are invisible
in the result and fatal if removed. The input source must be selected AFTER
the app takes focus — focusing resets it to ABC. And the IME must be warmed
until an observed keyCode 229; typed cold it emits raw QWERTY (g k s r m)
with no composition at all, which is indistinguishable from an IME that is
not installed. Two runs were voided on exactly that signature before the
warm-up was found.

The has229 and compositionStarts assertions exist to make such a run fail
loudly rather than pass as a clean negative.

Gated on darwin plus ORCA_E2E_NATIVE_MACOS_KOREAN, like its siblings. The
final spec form has not itself been executed — the machine became
unavailable — so it carries the probe's measured values as literals rather
than a run of its own.

* docs(e2e): correct 19a8d133db — the Korean preedit spec has been executed

That commit said the landed form had never run and carried the probe's
values as literals. It has now run on real hardware: 1 passed, 9.1s, rc=0,
with the capture and log sealed under a verified hash manifest.

The teeth check was also run rather than reasoned about, and it changes
which assertion matters. Forcing the active overlay to max-width:0 with
overflow:hidden — invisible on screen — leaves the active class, the
textContent, display:block AND checkVisibility() all passing. Only
during.rect.width fails. checkVisibility() is not sufficient against this
defect; the bounding rect is the single load-bearing assertion, which the
docblock already said and this run confirms.

An earlier teeth attempt injected the CSS mid-run and tripped the
hasActiveClass poll instead, failing at the wrong assertion. It is
inconclusive and excluded from the seal rather than counted.

* test(terminal): add #12171's ordinary-English arm from a real Windows capture

c4 was recorded as unmet and the ledger sourced its control to
evidence/windows-current/, which holds 12 captures and not one English one.
The arm here comes from windows-9803-final instead — same probe, same host
geometry, same injector, en-US with no IME, replayed keydown for keydown.

Two limits are stated in the file rather than left for a reader to find. It
is a different bundle and a different run about 3.6 hours later, so it is
not a same-run arm. And it is #9803's range-active MUTANT arm: ordinary
English stays byte-exact even with that saved-range mutation live, which is
why it reads as a negative rather than as a baseline.

Bundle cited by directory with its file SHA-256; MANIFEST.sha256 verifies
21/21, rc=0. Nothing imported from .tmp.

* docs(terminal): correct #12164's grounds — the cited comments say no such thing

The rejection of #12164 from this file's family was recorded as resting on its
comment 1 (output doubling) and comment 2 (filed against 1.4.163). Checked
against the API: the issue has exactly two comments, neither of which says
that, and the string 1.4.163 appears nowhere in the thread.

The conclusion survives on better grounds. The issue BODY's repro is "Run any
CLI agent (Codex, AGY, Claude, etc.) that outputs Korean text into the Orca
terminal" — untyped output, no keystrokes, no composition — so excluding
CompositionHelper is right, and the input-path hunt was looking in the wrong
place. The body is also LLM-authored (it still contains a literal
"## 5. GitHub Submission Draft (Ready to Post)") and its Root Cause section
blames a CJK IME preedit buffer its own repro never engages, so it should not
be read as observation.

Comment-only; suite unchanged at 5/5.

* test(native-chat): make composition frames carry isComposing, not just inputType

This suite's comment claimed "Gating onChange on `isComposing` breaks here."
It did not. composeFrame() fired `input` with `inputType` but never set
`isComposing`, so a gate on `isComposing` passed all four tests untouched —
the suite asserted a discriminator it did not exercise.

Composition frames now carry both, so neither gate is exempt. Verified by
pointing the mutant at it: with an `isComposing` gate on the composer's
onChange, this suite now fails 3 of 4 (it passed 4 of 4 before), and the
ordinary-English arm correctly survives, since a composition gate should not
touch it. Production code is unchanged and stays gate-free; the mutation was
applied, measured, and reverted.

Found while excluding NativeChatView's question-card remount as the owner of
#12118 / STA-3219: the remount is real, but the preedit survives it precisely
because this write path has no composition gate.

* fix(mobile): ship the patched xterm build, matching desktop

mobile pinned @xterm/xterm 6.1.0-beta.285 while the patch is keyed to
6.1.0-beta.287, so mobile shipped stock xterm and neither IME defect fix
reached it: the swallowed keystroke after an IME commit (9506039de7) and
the preedit sized to the font rather than the grid (e04e0c88da).

Bumps the three xterm packages to the desktop versions and adds the patch
to mobile's own pnpm.patchedDependencies. No copy of the patch: pnpm
accepts the parent-relative path and records it in the lockfile against
hash 8d63166272e9040a…, byte-identical to what desktop resolves, so the
two stay in step by construction rather than by a drift check.

The workspace separation is untouched — root pnpm-workspace.yaml still
declares `packages: []` and mobile keeps its own lockfile, which is what
keeps the root's patches from failing as ERR_PNPM_UNUSED_PATCH.

Verified in the generated webview bundle rather than at the install:
alignPreeditToGrid 0->2, sentComposition 0->3, pendingInput 0->11, and the
stock-only _handleAnyTextareaChanges 2->0 and dataAlreadySent 4->0. pnpm
applies patches during linking before postinstall regenerates the bundle,
confirmed by a revert/reinstall/re-apply cycle in both directions.

Mobile suite 2971 passed, 3 skipped — identical before and after. Bundle
+1,514 B (+0.24%). Lockfile churn is xterm-only; --frozen-lockfile passes.

mobile/src/ime/ime-submit-carry.ts is NOT made redundant and is untouched:
it handles iOS firing onSubmitEditing on a React Native native TextInput
after unmarking a composition, which is outside the WebView entirely.

Known divergence left alone: desktop also patches @xterm/addon-webgl and
mobile now runs that version unpatched. That patch is glyph/texture-atlas
rendering with nothing IME-related, so it affects neither fix.

* test(terminal): pin the preedit overlay against already-committed cells

STA-3132 (arm A), STA-3170 and STA-3232 report a Korean preedit painted on
top of text already on screen. Builds v1.4.163-v1.4.166 cancel the pending
finalizer in compositionstart, so a committed syllable reaches onData one
syllable late and buffer.x is stale — the overlay lands on the cell the
flushed syllable is about to occupy.

Replays a recorded hardware trace rather than an authored one: the ordered
DOM event stream captured on Windows + MS Korean (wave5-r2 evidence, 64
events), echoing onData back as PTY output.

The load-bearing assertion is deliberately not the obvious one. Comparing
overlay style.left against cursorX is tautological — left is computed from
buffer.x. This counts committed syllables from the compositionend events
the IME fired, so the two sides are independently derived.

Discriminated by a historical re-add across seven real bundles, since the
owner is deletion-shaped: pristine beta287, v1.4.155 and v1.4.162 pass;
v1.4.163 fails; v1.4.163 with that single call removed passes; the byte
identical baseline restored fails again; head passes. Every failing arm
fails only this case — the ordinary negative stays green in all seven.

The negative asserts its own category rather than claiming it: zero
composition events, zero isComposing, zero keyCode 229, exactly 16 events,
paired against the Korean arm's 4 starts / 3 ends / 11 updates / 64 events.

Scope: cell indices, not pixels. happy-dom has no layout, so the recorded
8x16 cell metrics are supplied to the render service. This makes no claim
about pixels visually overlapping; that is affected-OS confirmation and
stays open. Covers the overlap arm only — STA-3132's auto-line-break arm
and STA-3232's half-line-capacity and a11y arms are untouched.

* test(e2e): matrix macOS period substitution against the OS preference

#11504 reports macOS inserting ". " after a Hangul Space commit. This
sweeps six arms across both states of NSAutomaticPeriodSubstitutionEnabled,
reading the preference live per run rather than asserting a literal.

Two results worth having on record.

The reporter's stated trigger did not reproduce. Their words are "There is
no second press at all. One space is enough", but korean-single-space emits
zero insertText with the preference on or off. So does word-space-word-space.

Their timing does reproduce, with different content. korean-double-space and
korean-longer-word-double-space emit a delayed insertText at +122.5-122.7ms
after compositionend — squarely the reported +149ms — but the payload is a
space, never ". ". Consistent with the double-space rule seeing two slots
under ABC and only one under Korean, where the IME commit consumes the first.

The substitution itself is real and preference-bound: latin-double-space
gives "ab . " with the preference on and "ab  " with it off, on one build
with the preference as the sole variable, reproduced across two runs.

That also refutes a claim in PR #11506, which states the substitution "is
enforced outside the renderer and never reproduces in dev builds, so changes
here must be verified against a packaged app". It reproduced in the dev build
twice and did not reproduce on the signed packaged app. That claim should not
be used as a verification gate.

Gated @headful behind ORCA_E2E_NATIVE_MACOS_PERIOD, same shape as the Korean
preedit spec, so it does not run in ordinary CI. Evidence is onData and DOM
only — the PTY-child reader aborted and no packaged-app arm was stable.

* test(terminal): actually enforce the recorded jamo progression

The preedit assertion compared sample.overlayText against sample.overlayText
— the same expression on both sides. A lane proved it by mutation: corrupting
seven of the eight recorded preedit values left the suite fully green. So the
docblock's ㄱ→가→간→나→낟→다→달→라, which the matrix also cites as this row's
recorded shape, was cited and unenforced.

The first attempt at a fix was insufficient and is worth recording. Threading
stroke.preedit through to the expectation still passed on a corrupted fixture,
because that value both drives the rig and was the expectation — corrupting it
moved both sides together. Same tautology, one level down.

The expectation is now an independent literal. Verified by mutation rather
than by reading: corrupting two recorded values fails one arm; restoring them
passes 3/3.

overlayCell was never affected — it is compared against a count derived from
the compositionend events, not from the buffer, and remains the load-bearing
assertion for the overlap.

* fix(e2e): select the selectable input source, not the first match

TISCreateInputSourceList can return several entries for one input source
id. A third-party IME publishes a non-selectable parent alongside the
selectable mode, and taking sources.first can return the parent — after
which TISSelectInputSource fails with paramErr (-50) while the caller
reports success from the enable step.

Found with Qingg (com.aodaren.inputmethod.Qingg), which exposes exactly
that pair under one id. Its mode id equals the bundle id, so filtering by
name would not have helped; selectability is the discriminator.

Now filters on kTISPropertyInputSourceIsSelectCapable and falls back to
the old behaviour when nothing advertises it, so single-entry sources are
unaffected. Also enables every entry for the id rather than only the one
being selected: selecting a mode whose parent is still disabled fails the
same way.

Compile-checked, and selecting com.apple.keylayout.ABC still exits 0.

Unrelated to the enable path: on macOS 26.5.2 third-party IMEs are gated
behind a consent sheet in System Settings. TISEnableInputSource returns
noErr immediately regardless, and the enable only lands if that sheet is
answered while the requesting process is still alive.

* test(terminal): discriminate #12171 against the real shortcut policy

The prior candidate mutation for this row was correctly refused: its suite
mocked shortcut policy so Process/229 became actionable, while the real
resolveTerminalShortcutAction has no Process branch — so the kill measured
the mock. This does not mock it.

Replays a capture of this row's own gesture (d, l, Shift+T, e, k, Space,
Enter under MS Korean, committing 있다) taken on Orca 1.4.164, through the
real useTerminalKeyboardShortcuts hook, capturing bytes at terminal.input.
The earlier capture could not discriminate at all because it recorded no
shiftKey; this one records it on 10 of 10 keydowns with code populated.

One physical Shift+T produces two shifted Process/229 keydowns. Under the
pre-#12265 classifier each synthesizes {key:'Enter', shiftKey:true}, which
the real policy resolves to sendInput '\x1b\r' — twice, giving 1b0d1b0d,
the two escapes the known-bad ed96881b0d1b0d contains.

Mutation is the retained pre-12265-process-shift.patch applied to HEAD, not
an authored one: patch -p1 applies clean and diffs identical to the mutant
copy. Arms are copies; shared source hashes the same before and after.

The English arm stays clean under both modules, so the mutation
discriminates by language rather than by harness — and a real Shift+Enter
through the same rig yields exactly ['\x1b\r'] in every arm, so a silent
pristine result means the code is quiet rather than the harness dead.

Scope: 1b0d1b0d is measured at the renderer boundary. The capture recorded
no PTY bytes — window.api.pty is frozen on shipped builds and the onData
channel needs a build-time flag — so this shows the renderer producing the
two escapes that payload contains, not a re-observation of the payload.

* docs(native-chat): narrow this file's disclaimer to what is now true

It said "THIS OWNS NO REPORTED ROW". Half of that is stale: the remount site
is now the attributed owner of #12118 and STA-3219. On real Windows TSF the
questionActive swap aborts a live composition — the old node gets only a
blur and no compositionend, the text returns as committed, and the next jamo
yields 아ㄴ rather than 안.

The other half holds. This file pins the opposite property, that the text
survives, which is the half those reporters already agree with. Mutation
shows the gap rather than asserting it: deleting the unmount entirely leaves
three of four tests green, because every substantive assertion is
after.value === … and a composer that never unmounts keeps its value.

Also records why the abort cannot be asserted here. The DOM exposes no
observable separating committed text from a live preedit — value is the same
string either way, there is no EditContext, and the only composing-ness
state is a per-instance ref discarded with the node. A test pinning "no
compositionend fires" would be an anti-guard: red the day it is fixed.

The cadence objection is kept, since it is now the open question rather than
the reason for exclusion.

* refactor(terminal): drop the unread isComposing field from XtermBypassEvent

Added by #6396 for terminal IME candidate handling that this branch has since
removed. No production or test code reads it, and the policy is safe without it:
during composition `key` is 'Process', so the non-ASCII printable checks that
would care never match.

Co-authored-by: Orca <help@stably.ai>

* fix(native-chat): keep the composer mounted through an in-flight IME composition

A question card replaced the composer outright
(`{questionActive ? null : <NativeChatComposer/>}`). Unmounting the field
mid-composition aborts the composition in the OS: the node is detached before
`compositionend` can fire, the preedit returns as committed text, and a resumed
Hangul syllable degrades — 아 then ㄴ yields `아ㄴ`, never `안`.

Confirmed in rasterised pixels on Windows with a real MS Korean IME, at both
v1.4.171 and the reporter-era v1.4.164 (the swap block is byte-identical
across them): the preedit underline present before the swap, the composer
visibly absent during it, and the same glyph back afterwards WITHOUT the
underline — committed, not composing.

The swap is now deferred while a composition is in flight, which is what
editors that survive IME do: ProseMirror gates DOM work on `view.composing`,
CodeMirror protects the composing subtree from redraws. Hiding instead of
unmounting does not work — `display:none` and `visibility:hidden` both blur the
focused element and abort the composition the same way.

The hold releases on `compositionend`, which browsers also fire on blur, so
clicking into the card's own answer input yields the input region immediately;
with nothing composing the card still replaces the composer at once, so no
stray "Send a message" appears beside a question.

The existing characterization test flips to a regression guard: it pinned the
node being destroyed, which was the defect. Node identity is the load-bearing
assertion — value-only checks are trivially satisfied by a composer that never
unmounts and cannot tell a held composition from a destroyed one.

The typing-redirect handler moves to its own hook. That is not cosmetic: both
touched files sat at the 400-line cap, and `max-lines` suppressions are
forbidden, so the room had to come from a real extraction.

* fix(macos): opt Orca out of AppKit automatic period substitution

macOS "Add period with double-space" (`NSAutomaticPeriodSubstitutionEnabled`,
on by default) is applied by AppKit's text input system. Native terminals never
join that system; Chromium text fields do, so xterm's helper textarea inherits
it and a double space arrives as `". "` — a period nobody typed, handed straight
to the PTY (#11504).

Chromium answers AppKit for quote and dash substitution and defaults both off,
but declares no period accessor at all, so AppKit applies that one without
asking. This user default is the only lever: there is no per-field or
per-webContents opt-out to prefer over it. Writing the key into Orca's own
defaults domain overrides the global value for this app alone and leaves the
user's system-wide setting untouched. It necessarily covers every Orca text
field, not only terminals — AppKit offers no narrower scope, and that tradeoff
is deliberate rather than accidental.

Measured on the reporter's own build v1.4.161: with the preference ON, typing
a,b,space,space yields `onData ["a","b"," ",". "]`; with it OFF the same arm
yields two spaces.

Note the issue's causal model is wrong and this fix does not follow it. It
claims the substitution only fires with a CJK input source and never with ABC.
The measurement is the inverse — every Korean arm is clean and the ABC arm is
the one that fires — so the fix is not conditioned on input source.

NOT YET VERIFIED ON HARDWARE. The unit tests inject the writer, so they prove
the call is made on darwin and skipped elsewhere; they do not prove AppKit
honours an app-domain override for this key. That check is outstanding.

* fix(xterm): keep a live composition across a lone Cmd press on macOS

CompositionHelper.keydown exempted keyCodes 16/17/18 from tearing a composition
down, which covers Shift/Ctrl/Alt but not macOS Meta — 91/93 in Chromium, 224 in
Firefox. A lone Cmd press mid-composition therefore reached
_finalizeComposition(false), which dropped the preedit overlay's `active` class
and committed the live syllable early. macOS keeps the marked text alive across
that press, so no later compositionstart re-arms the overlay and the rest of the
word composes invisibly.

Measured on hardware (m4air, macOS 26.5.2, Apple M4, 2-Set Korean) with the Cmd
posted as a CGEventType.flagsChanged, which is what a physical modifier emits.
AppleScript `key code 55` posts nothing a browser can see — a bare `key code 56`
for Shift is equally silent — which is why no capture in the corpus ever reached
this branch. Three arms, same build otherwise: overlay live throughout with the
exemption, dark and prematurely committed without it, live again with it
restored. Evidence under
.tmp/ime-handoff/swarm-scratch/wave31-cmd-preedit/evidence/.

The fix cannot widen past a lone modifier: only a standalone press reports these
keyCodes, and a Cmd chord during composition is reported by Chromium as 229,
which was already exempt. Cmd+A still ends the composition, via the IME's own
compositionend. Ghostty draws the same line, returning early from flagsChanged
under hasMarkedText() for every modifier including Super.

Orca's terminal pane was never affected — shouldSuppressTerminalModifierKeyboardEvent
drops a standalone Meta keydown before xterm sees it, and deleting only 'Meta'
from that set is what flipped the hardware arm to broken. The popout preview
terminal and mobile's webview install no such guard and did reach the teardown.

terminal-ime-xterm-composition-commit-overlap.test.ts asked its fixer to update
the two Cmd arms to the values it named as correct; both now emit a single ['한'].

* test(native-chat): drop two byte-identical duplicate cases

`4632b86919d` copy-pasted two cases twice into the same describe block:
`retains carry across a same-frame non-Enter keyup before redispatch` and
`expires carry before a deliberate Enter after the next frame`. Each pair is
byte-identical — same title, same body — so the copies asserted nothing the
originals did not.

This is what has been failing `static analysis` on this branch since 2026-08-06:
`oxlint vitest(no-identical-title)` reports both under `--deny-warnings`, and
`verify` fails solely because it requires static analysis to pass. Every other
gate in `verify` was already green, including typecheck, xterm patch sync, the
full test shard set, and both package jobs.

12 cases still pass in the file.

* test(e2e): skip the WebGL arm when no WebGL renderer exists

The #12164 probe runs two arms, webgl and dom, and closes by asserting the
active renderer is the requested one. That assertion is right for the dom arm —
it is what proves the pane actually left WebGL, without which the arm is
meaningless — but headless CI has no GPU, xterm falls back to DOM silently, and
the webgl arm then fails.

The failure reads as a Korean rendering defect and is not one, so the webgl arm
now skips with the active renderer named. The dom arm keeps the assertion
unchanged.

This is the third of three checks that have been red on this branch since
2026-08-06. `static analysis` and `verify` were fixed in c51c6b5837e; the CI log
shows this job as 1 failed / 1 passed, the pass being the dom arm.

* chore(lint): drop five unused no-console disable directives

`check-changed-code-quality` reports unused eslint-disable directives as errors,
and these five sat above diagnostic `console.log` calls in IME test and spec
files where `no-console` is not enabled — so each suppressed nothing.

This is the second of the two static-analysis steps. `c51c6b5837e` fixed
"Enforce focused code-quality plugins" (duplicate test titles); this fixes
"Enforce changed-code quality". Both had been red on this branch since
2026-08-06, and I mistook the first for the whole job.

The diagnostic logs themselves are kept — they are what a failing IME arm prints
for a reader to inspect.

* test(e2e): cover #12164 under fractional device scale factor

Fractional display scaling was #12164's last unexplored branch, and the reason
is worth recording: earlier attempts were BLOCKED, correctly, because they
proposed mutating the Windows display scale on a remote physical machine with no
console recovery. `--force-device-scale-factor` reaches the same renderer state
per process, so nothing outside the Electron instance changes and there is
nothing to restore.

The hypothesis was specific: `프프로로젝젝트트` is what a half-pixel cell boundary
could produce on a 2-column glyph, and nothing else in the suite varies dpr.

Measured at 1.25 and 1.5, both under WebGL: ink extents 25/21/16 with identical
ink groups, matching the scale-1 run. No doubling.

The arm self-certifies before asserting — if the flag does not take, the test
fails rather than silently measuring at dpr 1. That matters here: the sibling
spec's WebGL arm went two days reporting a missing GPU as a Korean rendering
defect precisely because a silent fallback looked like a result.

* fix(terminal): match Mod+letter shortcuts by physical key, not IME-rewritten key

With a CJK input source active, macOS and Windows report the physical key through
`code` but rewrite `key` to the layout's character: Korean 2-Set turns Cmd+C into
`{ key: "ㅊ", code: "KeyC", metaKey: true }`. Every `key.toLowerCase() === 'c'`
match misses it, so the shortcut is not recognised and xterm encodes the chord as
PTY input instead — issue #13033 reports `ESC[12618;9u` and a terminal that jumps
to the bottom, because user input scrolls the viewport.

This is the same key-vs-code confusion that owned #12171, where a `Shift+T`
typing ㅆ was read as Enter for want of a `code` guard, so the fix is the same
shape: trust `code` when it is present, fall back to `key` and then the legacy
`keyCode` when it is not (Chromium omits `code` on synthetic and some keypress
events, and `keyCode` keeps its US value even when `key` is rewritten).

Applied to the four terminal-side sites, including the dashboard pop-out, which
#13033 called out specifically as having its own key handler:
  pty-connection.ts            Cmd/Ctrl+C copy guard
  keyboard-handlers.ts         Cmd+G search navigation
  agent-interrupt-inference.ts interrupt inference
  preview-terminal-key-handler.ts  pop-out paste

Nine further `key.toLowerCase()` letter matches exist outside the terminal
(TaskPage, editor, GitHub composer, browser markup). They have the same defect
and are deliberately left for a separate change rather than widening this one.

An existing case, `matchSearchNavigate > returns null for wrong key`, overrode
only `key` and left `code: 'KeyG'`, so it began passing for the wrong reason. It
now overrides both — which is what "wrong key" means once matching is physical —
and a companion case pins the Korean-rewritten chord still matching.

#13033 was closed NOT_PLANNED; the reporter's event shapes drive the new test.

* fix(renderer): match every Mod+letter shortcut by physical key, not IME-rewritten key

Completes the previous commit. A CJK input source rewrites `event.key` while
`event.code` keeps the physical key, so `key.toLowerCase() === 'z'` and friends
silently stop matching — the shortcut is not recognised and the keystroke falls
through to whatever handles unclaimed input.

The helper moves to `@/lib/ime-latin-shortcut-key` first: it now serves the
editor, GitHub composer and browser markup, and importing terminal-pane
internals into those would be the wrong direction. `lib/` already hosts
`ime-composition-keyboard-event` for the same reason.

Nine remaining sites, all previously unreachable under Korean/Japanese/Chinese/
Vietnamese input:
  TaskPage, ActivityPrototypePage, ProjectViewWrapper   Cmd+F search
  useMarkupKeyboardShortcuts                            Cmd+Z undo
  GitHubMarkdownComposer, RichMarkdownLinkBubble,
    rich-markdown-link-shortcut                         Cmd+K link
  native-chat-shortcut                                  Cmd+J
  rich-markdown-key-handler                             Cmd+Shift+X

Six of the nine test `!== 'letter'` as early-return guards and three test
`=== 'letter'`; the negation is applied per site, since a blind substitution
would have inverted six of them.

Full suite: 4249 files pass. Three files fail locally and none is caused by this
change — the branch touches no file under `src/main/` or `src/relay/`, all four
failures reproduce on an unmodified tree or pass in isolation (the worktree
poller passes 21/21 alone, so it is full-suite parallelism), and all 16 CI test
shards are green.

* docs(ime): scope the IME composition rules to the terminal-pane directory

#11893 proposed adding these to the root `AGENTS.md`, which every agent loads on
every task regardless of what it is doing. They only bind keyboard handling, the
composer and the terminal input path, so they belong next to that code —
`tests/e2e/AGENTS.md` already establishes the nested convention here.

Kept from #11893: range-derived commits, guarding above the key dispatch,
the `attachCustomKeyEventHandler` / `CompositionHelper` interaction, no
normalization at commit, and the recorded-trace evidence bar.

Added from defects found since it was written:
- match shortcuts on `event.code`, not `event.key` (#12171, #13033)
- `keyCode === 229` means an IME owns the press
- do not unmount a field mid-composition, and hiding is not a fix because
  `display:none` blurs and aborts it too (#12118, STA-3219, #11332)

The evidence bar now also names the mutation check, since a test that survives
deleting the code it guards is guarding nothing — a failure this effort hit more
than once.

* fix(terminal): gate Ctrl+Enter CSI-u on a negotiated pane, porting #12462

Found while scoping the rebase onto `main`: #12462 landed on 2026-08-06 and
fixes a real defect this branch does not carry. Ctrl+Enter emitted
`\x1b[13;5u` unconditionally, so a pane that never negotiated the kitty
keyboard protocol — local Windows ConPTY, plain shell — printed the escape
verbatim into the prompt.

This branch deletes `terminal-ime-deferred-newline.ts`, which is one of the
files #12462 touched, so a rebase resolving those conflicts by taking our side
wholesale would silently reintroduce the defect. Porting it forward now means
the fix survives the rebase however the conflicts are resolved.

Mirrors the Shift+Enter guard already here: local ConPTY falls back to the
legacy CR every emulator sends, and a negotiated pane keeps the chord, so the
fallback is scoped to panes that cannot receive CSI-u rather than to Windows.

NARROWER THAN #12462 BY ONE CONDITION, deliberately. `main` also allows CSI-u
via `hasCtrlEnterCsiUAuthority()` (trusted consumer evidence, #12329); that
helper and its plumbing do not exist on this branch. Omitting it is the
conservative direction — an authorised pane gets `\r` instead of the chord,
rather than an unnegotiated pane printing an escape — but it should be restored
when the two histories are reconciled.

Test covers both directions and is mutation-checked: forcing the gate open
fails it, so it cannot pass by construction.

* fix: reconcile two more fixtures main moved while the stack waited

Both caught by CI, not locally, and the reason the local run missed one is
worth recording:

1. `browser-toolbar-profile-dialogs.ime-enter.test.tsx` did not pass
   `useNativeUserAgent` / `onUseNativeUserAgentChange`, which `main` added to
   `BrowserToolbarProfileDialogsProps`.

   Local `pnpm typecheck` reported 0 errors on the same commit CI failed. The
   cause was a stale `config/*.tsbuildinfo` — tsc reused an incremental cache
   from before the merge. Deleting it reproduced CI's error exactly. Any
   "typecheck clean" during this merge should be treated as unverified unless
   the cache was cleared first.

2. Localization keys for `SshDisconnectedDialog` were absent from `en.json`:
   the merge took this branch's component alongside `main`'s catalog.
   Regenerated with `pnpm run sync:localization-catalog` rather than hand-added.

* fix(mobile): regenerate the lockfile the merge resolved by taking one side

CI's `verify` failed with `ERR_PNPM_OUTDATED_LOCKFILE` on `mermaid (lockfile:
11.16.0, manifest: 11.16.1)`. The mismatch was in `mobile/`, not the root — the
root lockfile was consistent throughout, which is why inspecting it (and even
GitHub's merge ref) found nothing wrong.

Cause: during the merge I resolved `mobile/pnpm-lock.yaml` by taking this
branch's side wholesale rather than merging it, so it kept `mermaid 11.16.0`
while `mobile/package.json` came from `main` at `11.16.1`. Taking one side of a
lockfile is only safe when the corresponding manifest comes from the same side.

Regenerated with `pnpm install --lockfile-only`; `--frozen-lockfile` now passes
in `mobile/`. Verified the xterm patch entry survives intact — same hash
`4f1b42d268f3964d…` and the parent-relative path into `config/patches/`, which
is the desktop/mobile coupling that would silently break the mobile build.

Two earlier diagnoses of this failure were wrong and are worth recording: it was
not the root lockfile, and it was not a stale merge ref (a rerun reproduced it
exactly).

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-07 22:58:40 -07:00
Wooseong KimandJinwoo-H e68831f32c fix(github-project): index fork upstream slugs for project row matching (#12822)
* fix(github-project): index fork upstream slugs for project row matching

Project cards often reference the public upstream repo while the open
clone's origin is a personal fork. Map the parent slug to the same Repo
so selected-repo filters no longer hide every board row.

Preserves origin-based getRepoSlug identity for non-project callers.

Fixes #12647

* fix(github-project): match project rows against fork upstream slugs

Resolve the referenced call to a nonexistent `resolveRepoUpstreamSlug` and
match the persisted `repo.upstream` parent instead of issuing an extra
`github.repoUpstream` RPC per repo on every index build — that lookup shells
out to `gh repo view` for non-forks, so it would have gated the Projects tab
on N network calls. `repo.upstream` is already resolved at repo-add time and
backfilled at startup, so the fix costs no IPC.

Origin matches take precedence over upstream ones so an open clone of the
upstream repo itself is never made ambiguous by someone's fork of it.

Also covers the two surfaces the origin-only match broke alongside the desktop
table: mobile's project row matcher and the store-slice row-mutation routing.

* fix(github-project): scope fork upstream matching by host and selection

Round-1 review fixes on top of the upstream-slug index:

- Apply origin-over-upstream precedence among *selected* repos instead of
  globally. An open-but-unselected clone of the upstream repo was shadowing the
  selected fork, so #12647 still reproduced for anyone holding both — and repo
  selection collapses to one repo per project key, which is exactly that case.
- Scope a fork's upstream identity key to the fork's own origin host.
  Persistence strips upstream.host, so GHES forks never matched their own rows
  and a GHES fork's parent could bind a same-named github.com row.

* fix(github-project): skip the fork alias when its own origin is unresolved

Round-2 review fix. `githubHostFromIdentityKey` cannot tell "origin resolved to
github.com" from "origin did not resolve" — both yield no host. A GHES fork
whose slug resolution had failed (auth lapse, unreachable runtime) therefore
landed in the github.com namespace, so an unrelated public Project row matched
it and Start work opened the wrong clone on the wrong server.

Require a resolved origin before indexing the upstream alias: it is the only
host evidence there is, and a repo with an unresolved origin was already absent
from the origin index, so nothing is lost that origin matching had.

* fix(repos): persist the fork upstream host instead of dropping it

`sanitizeRepoUpstream` kept only `{owner, repo}`, so a fork's parent lost the
server it lives on every time the record round-tripped through disk.

That forced the Project row matcher to re-infer the host from `origin`. The
inference is right for an API-resolved fork parent — `getRepoUpstream` stamps
`origin.host` there precisely because "a fork parent lives on the same server as
the fork". It is wrong for the other branch: a local `upstream` remote carries
its own host, so a github.com clone with a GHES `upstream` remote was indexed
into the github.com namespace, where an unrelated same-owner/name public repo
could claim it and Start work would open the wrong clone.

Keeping the host removes the guess. Absent stays absent, so records written
before this hydrate unchanged and the origin-derived fallback still covers them.
Also fixes the avatar for rehydrated GHES forks, which resolved against
github.com for the same reason.

* docs(github-project): correct upstream host fallback comment

Persistence now keeps non-empty upstream.host; originIdentityKey remains
the host fallback for older records without one (CodeRabbit nit).

* fix(github-project): own slug-index retry timer cleanup

Move the failure-retry setTimeout into its own effect so cleanup always
clears it. Scheduling from the async buildIndex then-handler failed the
react-doctor effect-needs-cleanup gate in static analysis.

* test(github-project): guard the slug-index retry timer, fix the mobile twin comment

Two follow-ups on 52298d82 and 2f89c20d:

- Cover the retry timer both ways: a failed resolution still re-resolves after
  the TTL and recovers the match, and the pending timer is gone after unmount.
  The second fails if the timer moves back into the async then-handler, so the
  property is guarded by more than the lint rule.
- The mobile matcher's comment made the same stale "persistence strips
  upstream.host" claim that 2f89c20d fixed on the renderer side.

* test(github-project): unmount slug-index hooks so React cannot flush after teardown

CI shard `tests node 24 6/16` failed with 10 unhandled
`ReferenceError: window is not defined` traced to this file. The tests mounted
hooks without unmounting, so React scheduler work flushed after the DOM
environment was disposed. All assertions passed; the shard failed on the
unhandled errors alone.

`cleanup()` after each test unmounts the trees. Does not reproduce locally in
isolation — it needs CI's worker pooling and file ordering.

---------

Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
2026-08-06 18:43:28 -07:00
Brennan Benson f6d0bde6fb fix(mobile): choose host for new workspace (#11647)
* fix(mobile): choose host for new workspace

* fix(mobile): close stale workspace host picker

* fix(mobile): disambiguate workspace host choices

* fix(mobile): keep host endpoint paths private

* fix(mobile): redact invalid host endpoints

* fix(mobile): handle opaque host endpoints

* fix(mobile): announce host picker options

* fix(mobile): harden workspace host picker

* fix(mobile): preserve host through workspace creation
2026-08-06 11:15:36 -07:00
Jinwoo Hong 73cd4c3f46 fix(mobile): bound live terminal input latency (#12763) 2026-08-05 13:52:14 -07:00
Jinwoo Hong a766ee4bcd fix(runtime): refuse to silently wake a deliberately slept pane (STA-3465) (#12672)
`activateMobileSessionTab` gated only on `publicTab.status !== 'ready'`. A deliberately slept pane publishes as `pending-handle` indefinitely — indistinguishable at that call site from a pane awaiting reconnect — so the reconnect probe added by #11542 respawned it with a re-resolved agent launch, waking something the user had deliberately put to sleep.

The first attempt refused activation for any pane with a `worktree-sleep` record, applied to every path. Independent review found that broke the documented wake gesture: opening the tab IS how those panes are meant to cold-restore (`wake-sleeping-agents-in-background.ts`: "Those panes cold-restore --resume when their own tab is opened"). A mobile tap sends the byte-identical call the reproduction test used, and in three of four topologies no wake clears the record first — so the tap became a permanent no-op with no feedback.

This carries intent explicitly instead of inferring it. A new shared `TabActivationIntent` ('user' | 'automatic') rides the existing ActivateTab schema as an optional additive field; `isAutomaticTabActivation` returns true only for an explicit 'automatic', so an absent value is permissive BY CONSTRUCTION in one place — an older client that does not send it keeps today's behavior rather than silently losing its wake gesture. The field is required on the mobile helper's params, so no call site can be added without declaring who asked.

Every user path (mobile tab switches, paired tab clicks, shortcuts, palette, the pane's own open) is labelled 'user'. The only automatic sender in the codebase is `waitForResubscribeHostSessionHandle`, the #11542 reconnect probe.

Verified per topology: user activation materializes a parked pane under headless serve, a paired runtime client, a completed agent with restoreOnTabOpenOnly, and a running agent whose wake cleared the record. The automatic probe is refused without retiring the surface, and #11542's reconnect tests stay green.

Also fixes a test fixture that made a real bug untestable: the store stub ignored the host id, so mutating the partition lookup to 'local' left the suite green. Correcting it exposed three existing SSH reattach tests that had been relying on that looseness — their workspace session sat in the local partition while their repo was SSH-hosted, a store production would never read. Production was always right; the tests described an impossible world.

Fixes STA-3465.
2026-08-05 01:28:07 -07:00
Jinwoo HongandOrcaWin 738f640428 fix(mobile): relay UX overhaul — steady status colors, visible relay dials, coordinated deep links (F1-F10)
* fix(mobile): keep healthy relays green through focus and network nudges (F1+F2)

Focus/app-resume nudges probe the active relay instead of suspending it;
network-change nudges replace it make-before-break, suspending only after a
failed dial. Mount, Retry, and host-swap windows read 'connecting' instead of
'disconnected'; the host list keeps last-known worktrees for every
not-connected state and spins instead of rendering nothing.

* feat(mobile): surface the pairing relay path in the pairing log (F3)

The relay candidate was silent during pairing: dialing, E2EE handshake,
director recovery, and the winning path now emit redacted phase lines through
the same connectOptions.onLog the direct path already used.

* docs(mobile): relay UX investigation findings and F0-F10 fix plan

* feat(mobile): name and narrate relay dials while they happen (F5)

migrateTo forwards the dialing session's connecting/handshaking/reconnecting
phases whenever the client is suspended or disconnected — never downgrading a
live session — and exposes getPendingPath so the host card can say
'· Orca Relay' during the dial instead of only after it.

* feat(mobile): race a relay dial when the direct dial stalls (F6)

A 2.5s grace timer starts relay recovery while an unauthenticated direct dial
is still inside its 12s connect window; the race gets one attempt through the
existing mutex/cooldown machinery, cancels when direct authenticates, and
never arms for hosts without a relay endpoint.

* fix(mobile): overlay the protocol gate instead of unmounting the host stack (F9)

A pending status.get used to swap the mounted HostStack for a spinner at the
moment the socket connected, destroying in-flight nested navigation. Once
children have rendered for a host they stay mounted under an opaque
touch-blocking overlay; first visits and blocked verdicts keep the old
behavior.

* fix(mobile): keep loaded data through transient connection blips (F10)

Git history no longer blanks on reconnect (and commit files refetch instead
of caching an offline empty answer), the repo picker keeps its last-good list
when an in-flight repo.list rejects, the diff review's ready-state
preservation actually runs, and proven host capabilities survive a drop
flagged unverified instead of being wiped.

* feat(mobile): coordinate every home deep push and bounce dead resume targets (F4+F7+F8)

Notification taps, the Accounts card, and host-edit now use the shared
mount-then-replace transition (with a focused-route walker so root-layout
scope works); the Resume card renders from the snapshot in a disabled state
so its late arrival can't shift Tasks under the thumb; resume targets are
validated against proven catalog data, and a session route whose worktree
the host proves missing bounces to the host index with a notice banner
instead of stranding on a dead screen.

* test(mobile): cover the resume-target and notice policies (F7)

Key notice dismissal by code so closing one banner cannot swallow a later,
different one, and move the visibility rule into host-route-notice.ts where it
is testable without a screen.

Adds the missing units for F7's decision points: isResumeTargetConfirmedMissing
(unproven catalog is silence, synthetic routes exempt), the validating
last-visited reader, and the notice visibility rule.

* fix(mobile): review-pass hardening for the gate overlay and diff preservation

Adversarial review findings: the reader's hunk position now survives a
connection blip (reset only on item change), the covered stack is hidden from
TalkBack while the gate overlay is up, and the overlay's hit-test comment is
scoped honestly to in-tree views (native-Modal drawers present above it —
follow-up).

* fix(mobile): CI + CodeRabbit review fixes for #12609

Move the findings doc under docs/ (root directory guard), drop two unused
eslint-disable directives, and address review findings: an unproven snapshot
seed can no longer downgrade a proven worktree catalog; a locally-aborted
relay dial skips the director fallback; post-migration bookkeeping failures
log instead of masquerading as dial failures (which could suspend the healthy
session); the auth wait arms its timeout before subscribing; forwarded dial
phases stop at close(); the legacy selector_not_found fallback requires
runtime_error; the diff-loading effect depends on the fields it reads; and
host-edit auto-cancellation is now pinned by a test.

* fix(mobile): second review round — queued replacements, race fence, confirmed bounces

A network-change replacement now survives the recovery mutex and cooldowns as
a queued intent instead of being dropped or suspending a healthy session —
only a failed dial or a dead probe tears one down. The happy-eyeballs
migration withdraws when direct authenticated during the relay dial
(first-authenticated-wins). A worktree bounce requires two consecutive
host-proven misses, since a transient desktop repo-scan rejection answers
selector_not_found for a live worktree. Background network flaps no longer
wake a billed relay splice, the lifecycle foreground flag stays in sync, a
screen unmount cancels only its own pending host-stack transition, and diff
review keeps the loaded review when its reconnect refresh rejects.

Extracted mobile-endpoint-nudge-router.ts and the establisher's dialEligible
pass, and split the supervisor nudge tests, to stay under max-lines.

* fix(mobile): satisfy the React Doctor changed-code gate

Render-phase ref writes move into effects: the protocol gate's resolved/mounted
latches now record committed outcomes only (a discarded children render can no
longer count as mounted), and the bounce hook syncs its callback ref in an
effect. Array<T> annotations become T[] in the extracted modules.

* fix(mobile): keep the loaded diff when the reconnect refetch rejects (F10)

The diff-loading hook's catch was the one path still erasing a ready diff —
the same keepLoadedDiff guard its disconnect and loading branches already use,
now pinned by a reject-after-ready test.

* fix(mobile): process foreground revival nudges

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 19:47:39 -07:00
Brennan Benson 39c3c58d55 perf(runtime): gate terminal.list visual layouts (#12450)
* perf(runtime): gate terminal.list visual layouts and stop the false writable claim

visualLayouts is ~31% of a large terminal.list payload (44,208 B of 137,412 B on a live 134-terminal remote runtime) and has exactly one consumer: the human-readable CLI formatter. Gate it behind an includeVisualLayouts request param that defaults to included, so pre-flag clients are unaffected, and have every --json/internal caller opt out.

Also drop the record-backed builder's writable, which was a verbatim copy of connected. terminal.show now states writability explicitly as exactly what terminal.send's PTY gate enforces.

* test(runtime): type the payload-size fixture arrays for tsc

* fix(runtime): preserve terminal list compatibility

* test(runtime): guard terminal list optimization

* fix(cli): preserve agent access to terminal layouts
2026-08-04 17:50:52 -07:00
Brennan Benson 9ee359550b fix(mobile): make native-chat file links and path citations tappable (STA-3331) (#12364)
* fix(mobile): make native-chat file links and path citations tappable (STA-3331)

- Linkify POSIX absolute paths in chat prose (leading-/ regex alternative;
  URL guard now keys off the char before the matched slash)
- Parse agent-style path:line(:col) citations in prose, code spans, and the
  open flow; line/column ride into the mobile file preview route
- Route non-web markdown hrefs (file: URIs, relative/absolute paths) to the
  file opener instead of silently dropping them; unknown schemes stay dead
- Resolve chat paths against the worktree root, not the terminal's live cwd
- Reuse the terminal tap-to-open flow for chat taps (haptic, preview route,
  tab activation with retries) via a shared identity-stable hook, and toast
  on misses instead of silent no-ops
- Keep snake_case paths whole (intraword underscores are literal text),
  scan bold/italic/strike spans for paths, split trailing punctuation off
  autolinks, and let taps land while the composer keyboard is up

* fix(mobile): harden chat file tap handling

* refactor(chat): share native chat href routing

* fix(mobile): detect files directly under path roots

* fix(mobile): keep inline tokens and dunder paths intact around emphasis

Review follow-ups on the chat file-link work:

- A rejected intraword `_` token left the scan index past its closing
  underscore, so every inline token between two snake_case words was
  swallowed and rendered as literal source — including markdown links,
  which became untappable. Rescan from just past the opening delimiter.
- Treat a path separator as an intraword flank so `src/__init__.py` and
  `a/__tests__/x.ts` stay whole; previously they rendered as bold plus a
  remnant that the new absolute-root pattern turned into a tap on `/x.ts`.
- Bound the `:line(:col)` tail so `src/app.ts:1e3` and `:80%` no longer
  parse a line number, while a cited range still opens its first line.
- Route chat tap failures through the composer banner (toast fallback):
  chat taps happen with the keyboard up, which covers the toast.
- Drop the tap-handler mirror's dep list; the call site rebuilds its
  accessors every render, so it could never skip on a route that
  rerenders per keystroke.

* Revert "fix(mobile): keep inline tokens and dunder paths intact around emphasis"

This reverts commit 308bfaf22b.

* fix(mobile): preserve chat file-link parsing and feedback
2026-08-04 15:46:08 -07:00
e59a319ffe fix(sidebar): keep each project's entry-point workspace visible under "Hide sleeping" (#12257)
"Hide sleeping" swept each project's main workspace out of the sidebar as soon as
it had no live PTY, browser tab or agent — even with "Hide default branch" off.
For a project whose only row is that workspace (a folder workspace, a fresh
clone, a detached-HEAD main), the entire project vanished with no in-place way
back.

Adds a shared `isSleepingSweepExemptWorkspace` predicate keyed on
`isMainWorktree` rather than the branch name, so folder workspaces (no branch),
detached-HEAD mains, and SSH rows whose head/branch are blanked while a provider
is disconnected all stay put. Wired into `computeVisibleWorktreeIds` (sidebar,
Cmd+1-9, workspace board), the jump palette's duplicate inline pass, and mobile's
`filterWorktrees`.

Ships default-on with an escape hatch: a persisted
`alwaysShowDefaultBranchWorkspace` setting surfaced as "Except default branch"
under "Hide sleeping". Explicit "Hide default branch" still wins, since it
filters before the sleeping sweep.

Mobile reads the setting but never writes it back, so a desktop opt-out can't be
clobbered by a filter tap before the ui.get roundtrip lands.

Combines the two PRs open against #8873. #8966's exempt set is a strict subset of
this one, so its production diff was subsumed rather than ported; its jump-palette
render harness and e2e spec were carried over, and are the only such coverage here.

Fixes #8873
Closes #8966

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:55:17 -07:00
Shahar MorandOrcaWin 026ed921c1 fix(mobile): keep main-buffer TUI footer above the iOS keyboard (#9178)
* fix(mobile): keep main-buffer TUI footer above the iOS keyboard

The iOS keyboard-avoidance lift anchored on the terminal cursor row. Pi's
TUI renders in the main screen buffer (not the alternate screen) with its
footer/status rows below the input caret, so the altScreen full-lift branch
was skipped and those rows stayed under the raised dock / keyboard.

Anchor the lift on the bottom-most non-blank viewport row instead of just
the cursor: the WebView now emits contentBottomRow, and the lift uses
max(cursorY, contentBottomRow). This generalizes the alt-screen case,
keeps short output at the top put, and matches prior behavior for a
scrolled shell prompt.

Extracted the lift into a pure, unit-tested function
(terminal-keyboard-avoidance-lift.ts) and moved metrics parsing into a
tested helper on the contract.

* fix(mobile): preserve keyboard metrics through notification dispatch

* fix(mobile): harden terminal keyboard metrics

* fix(mobile): ignore unstyled terminal whitespace

* fix(mobile): preserve decorated terminal whitespace

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 20:25:46 -07:00
Brennan Benson 0586bab4f9 fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337) (#12362)
* fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337)

An empty scrollback frame with absent host dims was coerced to 80x24, which
never equals a phone viewport, arming a zero-delay unsubscribe/resubscribe
loop (~25/s) that broke long-press gestures and drained battery.

- Absent host dims now hold the stream instead of resubscribing.
- Fit resubscribes are budgeted per handle (3 attempts, escalating backoff)
  with an absence-gated refill mirroring the chat-side rearm bound; on
  exhaustion the view degrades visibly via toast instead of hot-looping.
- A fresh post-measure match counts as convergence instead of resubscribing.
- setTerminalModes keeps the Map identity when the mode is unchanged, so
  same-mode frames no longer re-render the session route.
- Host emits the subscriber viewport as scrollback dims when the snapshot
  and PTY size are both unavailable, so current hosts converge immediately.

* fix(mobile): cancel stale viewport retries after convergence
2026-08-03 17:55:30 -07:00
Brennan Benson 3d68212f7b fix(mobile): surface worktree catalog failures instead of silent 0 worktrees (STA-3123) (#12235)
* fix(mobile): surface worktree catalog failures instead of showing 0 worktrees (STA-3123)

A connected host whose worktree.ps request fails now shows an explicit
catalog-failure state (with the RPC error code) on the host page, and
'Worktree list unavailable' on the home host card, instead of silently
rendering as a healthy host with zero workspaces.

* fix(mobile): mark cached worktree catalogs unavailable
2026-08-03 00:48:30 -07:00
Brennan Benson 2efc6e5476 fix(mobile): stop support modules from registering as routes (#11652)
* fix(mobile): keep support modules out of Expo routes

* test(mobile): parse Expo route exports

* test(mobile): reject platform-specific API routes

* test(mobile): reject platform API routes unconditionally
2026-08-02 15:53:06 -07:00
Brennan Benson c2e3d13efe fix(mobile): focus Kimi terminal input after touch (#11865)
* fix(mobile): focus terminal input after TUI touch

* fix(mobile): defer terminal focus after WebView taps

* fix(mobile): reset deferred terminal focus on route blur
2026-08-01 01:40:55 -07:00
NeilandBrennan Benson 6e7ceafd07 perf(mobile): avoid unchanged worktree catalog payloads (#11735)
* perf(mobile): avoid unchanged worktree catalog payloads

* fix(mobile): isolate catalog snapshots by limit

* review: reassert host truth on unchanged polls; content-address snapshots

Client — the `changed` gate meant an unchanged poll skipped setWorktrees /
setLastKnownWorktrees / setCachedWorktrees, so optimistic local edits
(togglePin, handleDeleteWorktree's failure re-add) and the #8498 cache guard
were no longer repaired while the host catalog was stable. The gate bought
nothing: setCachedWorktrees is an in-memory Map write and areWorktreeListsEqual
already ran every poll, so the steady state still short-circuits on array
identity. All wire savings are unaffected. admit() now just returns the
confirmed rows and HostScreen applies them exactly as it did pre-PR.

Also on the client:
- a stale response from a superseded client/host no longer clears the token the
  current client/host just established
- discriminate on `worktrees` rather than on `'unchanged' in response`, so a
  future catalog field named `unchanged` can't reclassify a full response
- useRef over useMemo for the snapshot client; React may discard memoized values
- hoist WORKTREE_PS_FULL_LIMIT so the truncates-at-200 rationale travels with it

Host — replace the per-limit snapshot cache with a content-addressed id (ETag
semantics). Ownership lives in the id, so concurrent clients, differing limits,
and runtime restarts are correct by construction; this drops the LRU, the
eviction policy, the per-runtime WeakMap, and the retention of up to 8 full
catalogs. The remaining cache is a pure memo: because ids derive from content,
dropping or thrashing it costs CPU and nothing else. Keeping the memo also
keeps the measured steady-state cost — hashing every poll instead measured
2.24ms vs 0.75ms for the compare on a 310KB catalog.

Verified: mobile 2784 passed / 3 skipped, src/main/runtime/rpc 1064 passed,
node + mobile typechecks, oxlint, oxfmt, max-lines ratchet.

* fix(runtime): isolate catalog snapshot memo

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-31 23:12:13 -07:00