Commit Graph
840 Commits
Author SHA1 Message Date
OrcaWinandm4air d17a17684b Reduce redundant CI runs, pnpm uploads, and fixture startups (#23145)
* Reduce redundant CI runs, store uploads, and fixture processes

* Avoid repeating draft-independent mobile checks on readiness

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 01:17:51 -07:00
OrcaWinandm4air 9b30c7f60a ci: verify mobile disposal and balance unit-test costs (#23114)
* ci: verify mobile disposal and reduce unit scheduling costs

* docs(ci): clarify timing assignment validation

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 00:12:06 -07:00
OrcaWin 6fc3cdcad6 Bundle Bun for headless Orca and profile persistence (#22635)
Bundle a pinned, verified Bun runtime for headless Orca so existing Node launch commands can hand off before opening a profile. Keep desktop execution on Electron.

Add the Bun SQLite adapter and terminal backend, bounded shutdown, process inspection and cross-platform artifact qualification. Keep future managed SSH deployment separate from current production launch paths.
2026-09-25 22:49:06 -07:00
OrcaWinandm4air 38bcdf76ac perf(ci): reduce queue pressure without paid runners (#23053)
* ci: measure complete unit file costs for shard balancing

* perf(ci): reduce repeated PR setup and capture complete shard timings

* perf(ci): seed reusable main-branch native and typecheck caches

* fix(ci): stop superseded unit workflows from resisting cancellation

* perf(ci): reuse bundle fixtures and share the baseline Git build

* ci: record hosted gains and refresh main hook parity

* ci: retain default workers after performance-budget regression

* docs: record hosted mobile timing flake

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-25 22:48:28 -07:00
OrcaWin 82412dab8b Persist profile state in SQLite with background writes (#22612)
Migrate profile state to SQLite and move writes and backups into a background worker. Acknowledge terminal, SSH and automation changes only after durable saves. Preserve JSON import, recovery, rollback and compatibility exports.

Validate migration, worker failures, maintenance, cross-profile moves and terminal lifetime races with unit, integration and end-to-end coverage.
2026-09-25 22:47:33 -07:00
Brennan Benson 067975bfd1 fix(native-chat): every lease latch has a way to die (#22820)
* fix(native-chat): every lease latch has a way to die

A failed exit settlement no longer leaves the lease in recovery: the release
writes no stage and keeps the exit in its death evidence, and whatever the dead
generation left running is settled from that evidence at the next acquire or
read restore. The settlement retry flag, its disposition and every branch that
read it are gone. A reservation that recorded no process is released at
startup and after a failed start, the never-written conflicted status and the
processless proof are deleted, recovery resolution always concludes, and Codex
records its child's identity at spawn, before the handshake.

* test(native-chat): a re-create needs a release proven by death evidence

* test(codex): the child's pid is reported before the handshake

* test(native-chat): type the crash and exit fixtures without casts

* fix(native-chat): wait out a terminal owner an older build recorded, in recovery rather than manual recovery

* test(native-chat): a chat mid-turn at quit reopens idle, and an older build reads an unproven release

* test(native-chat): explain the baseline store cast

* fix(native-chat): a terminal owner's refusal names the process instead of recursing

Opening a chat whose terminal owner an older build recorded threw a stack
overflow instead of the refusal that names the process to quit.

* fix(native-chat): wait out a terminal owner recovery cannot verify instead of releasing it

A terminal agent an older build recorded keeps its PTY across an Orca
restart, so a probe that cannot answer (a start-time read that fails on a
loaded host) is not evidence its transport is gone. Releasing it let a
native child resume the same conversation beside the live terminal agent.
Only proof of its exit now ends the claim.

* ci(cross-version): run the unproven-release downgrade test

The sharded unit job excludes tests/e2e/cross-version-wire, and the
cross-version job runs an explicit list that did not name the new test,
so it never ran in CI. A change to the record validator now also starts
the job.

* refactor(native-chat): map the retired manual-recovery stage to recovering at decode

Nothing in this build writes manual-recovery, and restart reconciliation
already rewrites it. Mapping it where the other retired handoff stages are
mapped removes it from the in-memory lease type and deletes the branches
that could only see it: the acquisition refusal, the renewer skip, the
unproven-release stage check, and the handoff-status 'manual recovery is
required' answer. Older builds accept recovering, so a record written back
still loads after a downgrade.

* docs(native-chat): say what happens to a live child an ownerless reservation leaves

The reaper runs once at store open, while the unreconciled lease still
claims the child's token, so it does not stop that child on this launch.
The comment claimed it did.

* test(native-chat): name the each-case label for its role

* fix(native-chat): continue a create retried after recovery released its reservation

The client retries a create it never heard back from under the same operation id.
Recovery had released that create's reservation, so the retry was refused
agent_session_ownership_unknown while its row was pending, and
agent_session_operation_expired once the row aged out, and the chat never started.
A retry whose lease nothing holds now continues as a fresh reservation at the next
fence, which also stops the old reservation's spawn from committing.

* test(native-chat): name the refusal a replayed create used to get

* fix(native-chat): one quit-the-terminal-agent message for a chat a terminal agent holds

A chat held by a terminal agent an older build recorded frees only when that agent
exits. Sending said to reopen the chat and opening it said two runtimes claimed
it; both now say the chat is open in a terminal agent, name its process, and say
to quit it. Error codes are unchanged.

* ci: run PR checks on the rebased head

* fix(native-chat): name a terminal owner's process only when its start time can tell it from a reused pid

* test(native-chat): relaunch from the dying host's durable state, so its still-pending attach cannot race the new host
2026-09-25 21:04:31 -07:00
Jinwoo Hong 570f070f6c fix(mobile): the page's Live input on Android echoes each letter as typed (OTA phase C follow-up) (#22958)
* test(mobile): the page's live input sends a composed word's letters as they are typed

The reported shape, in a browser: `/tui` on an Android keyboard, where the `/` reached the
terminal and `tui` did not until Enter. Android keyboards hold a composing region over the Latin
word being typed, so on the page every input event mid-word carries `isComposing: true`, and the
preedit mirror holds reported preedit with no settle timer. Native Android reports no range, so
there each ASCII keystroke is sent as it is typed.

Driven through Chromium's own IME path with an Android WebView user agent: the letters after the
slash must arrive one by one, and a correction on commit must still erase and retype. The iPhone
case is the guard that a composition off Android stays held. The hook case says the same thing
without a browser.

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

* fix(mobile): the page on Android reports no composing range, as native Android does

`handleLiveInputChange` passed `nativeEvent.isComposing` straight to the preedit mirror. On the page
that event is the DOM's, and an Android WebView marks the keyboard's composing region, which Samsung
and other Latin keyboards keep over every word. The mirror treats a reported range as preedit that
is not text yet, holds all of it with no settle timer, and Chromium fires no input event after
compositionend, so the word sat in the field until the next keystroke or Enter.

Native Android reports no range at all, and its fallback holds only a trailing non-ASCII run. The
page on Android now reports the same: undefined. ASCII echoes on each key, a Hangul or kana run
still settles on the timer, and a correction on commit still erases and retypes. iOS, where the
range is the text system's marked text, is unchanged.

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

* docs(mobile): name the Android page among the platforms that report no composing range

The mirror's fallback note listed only React Native Android; the page on Android now reports no
range too. The reader's docblock becomes the one line it needs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 20:24:54 -04:00
Jinwoo Hong c9d421f3c1 fix(mobile): the page's terminal shows its last rows above the command dock (OTA phase C follow-up) (#22806)
* fix(mobile): mount the page's terminal frame with its onLayout

On the page every branch of the session content is a bare View in one
slot, so the terminal frame reused the loading View. react-native-web
observes onLayout only on a View that mounts with it, so the frame never
reported its height: every measure fell back to the page's
window.innerHeight and the PTY got rows the frame cannot show (63 vs 47
on a 1280x2856 screen), with the last rows under the command dock.

Keying the frame makes it mount fresh. The parity pin moves for the key
string.

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

* fix(mobile): size the terminal document to its host, not the window

Inside the WebView the window is the terminal frame; on the page it is
the whole page, header and dock included. Every fit, pan, scroll and
overlay bound in the document read window.innerWidth/innerHeight, so a
measure without a container height sized the PTY to the page.

The document now reads one seam, viewportSize: the window by default
(native unchanged), the host element's box on the page. A census keeps
raw window size reads out of document/.

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

* fix(mobile): put the terminal document's coordinates in its host

Round one moved the page document's size to its host but left its
origin at the window, so a tap below the session header mapped rows low
(row 19 for row 11), and the selection overlay and scroll indicator,
position: fixed, drew over the page.

- viewportRect replaces viewportSize: the host's box on the page, the
  window at 0,0 in the WebView. viewportPoint is the one place a client
  point meets that origin; cell mapping, mouse reports, the pinch anchor
  and edge scroll go through it, and a census holds the rest to deltas.
- observeViewport: the fit refits on a host ResizeObserver on the page,
  a window resize in the WebView.
- A hidden host measures 0x0; the fit keeps scale 1 rather than 0.
- On the page the host is the overlays' containing block.

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

* test(mobile): state what native measured in the parity note

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

* test(mobile): read the page terminal's overlay and refit off its host

The overlay is absolute inside the host now, and the page's refit
follows the host's box, so the owed-frame case pulses the host rather
than the window.

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

* fix(mobile): hold the terminal fit while its host has no width

computeFitScale answered 1 for a 0-wide host, but applyFitScale still
committed that fit as soon as the cell width was known, so a display:none
screen got a fit for no box. The attempt now leaves the fit pending until
the host reports a positive width, and observeViewport starts it again
once it does: one commit, never at scale 0.

A regression test pins edge scroll to the host's edges: with the host at
top 100 and height 600, the down band starts at client Y 660, not 560.

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

* fix(mobile): skip the terminal refit while its host has no box

react-native-screens hides the session with display:none when another
screen covers it, so the host's observer reports 0x0. The refit still
clamped pan and repainted, so coming back to the session lost the pan
the user left; native never refits on navigation. The refit now does
nothing until the host has a positive width, and the next real box
refits once.

Also untangles the seam-count sentence in document-host-seams.

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

* fix(mobile): refit the page terminal only when its box changes

Coming back to a session takes the host from 0x0 to its old box. The
observer fired, the refit committed, and the user's pan and zoom were
reset, where native keeps them because navigation never resizes its
WebView. The fit now remembers the box it was committed for, and the
refit acts only on a positive box different from that one.

The frame tests wait on the document's own frames instead of a timer,
and the hidden-host case asserts the zero-width read happened.

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

* fix(mobile): refit a shown terminal when a fit was asked for while hidden

The zero-width gate drops a fit requested while the host is hidden, and
the same-box skip then kept the stale fit when the host came back at its
old size: 80 columns at 390 px stayed at the 55-column scale, 0.945
instead of 0.65. Any fit request now forgets the last fitted box, and so
does a text-scale change that could not resize a hidden grid. A plain
hide and show asks for no fit, so it still keeps pan and zoom.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 06:03:37 -04:00
NeilandDeryFerd 46079aa20b fix(lint): include .mts and .cts in line-limit checks (#22413)
Co-authored-by: DeryFerd <DeryFerd@users.noreply.github.com>
2026-09-25 03:01:52 -07:00
Brennan Benson f9356d491a fix(release): run the tag's own skill freshness inventory tests in the release gate (#22775)
The skill-sharing release gate checks out the release tag, then restores
main's copy of skill-freshness-inventory.test.ts. That file holds behaviour
tests, so a behaviour test added on main runs against a tag that predates the
behaviour: #22606 added one and failed the v1.4.211 gate on macOS and Linux.
Keep restoring skill-provider-runtime-roots.test.ts from the workflow ref.
2026-09-24 22:47:59 -07:00
Jinwoo Hong 2c7609bf6a fix(terminal): serialize only the visible width after a column shrink (#22586)
* fix(terminal): serialize only the visible width after a column shrink

xterm does not reflow the alternate buffer (or a normal buffer under
pre-21376 ConPTY), so after a shrink each line keeps its old length.
SerializeAddon walked every non-final row to line.length, so any snapshot
taken after a shrink carried stale right-hand cells that wrapped into extra
rows on replay; restores repainted that garbage and a differential TUI such
as OpenCode never cleared it.

Clamp the row walk and the wrap-boundary lookups to the terminal's columns
in Orca's addon-serialize source patch, and regenerate the bundles, maps and
lockfile hash per docs/reference/xterm-patch-regeneration.md.

* test(terminal): read shrink-snapshot fixtures through public APIs

Drops the private-terminal casts the casting gate flags; the normal-buffer case
now drives a plain pre-21376 ConPTY terminal and its SerializeAddon directly.

* fix(terminal): blank a wide glyph clipped by a column shrink when serializing

After a non-reflowing shrink a width-2 glyph can have its lead half in the last
column and its trailing half past the grid. Serializing the lead half makes the
replay wrap it to the next row and shift every row below, so serialize that
cell as a blank and keep the row exactly the grid's width. A glyph ending
exactly at the edge is unchanged.

* test(mobile): move the session closure pin past the main agent status modules

#22452 added src/shared/main-agent-status.ts and src/shared/agent-turn-outcome.ts,
which agent-status-types.ts imports, so the session route's closure grew by two
local modules (4218 -> 4220). That change was src/shared-only, so its own CI never
ran this suite; main has been at 4220 since, and any PR that fires the mobile web
app job fails on the stale pin. Measured on 4064653740 and on origin/main 3ea15dd0a2.

* test(terminal): differential serialize round-trip fuzz and transcript replay

Seeded VT streams (text, CJK/emoji/combining, SGR, cursor/edit ops, scroll
regions, DECAWM/IRM, alt-screen variants, DECSC, and shrink-heavy resizes)
drive a source terminal in three modes: reflowing normal buffer, alternate
buffer, and a non-reflowing pre-21376 ConPTY normal buffer. At each checkpoint
every SerializeAddon build under test serializes it, and each output is
replayed into a fresh terminal of the same size and compared cell by cell,
plus cursor, active buffer and modes.

CI runs 25 seeds per mode against a pinned list of pre-existing divergences,
and replays the committed PTY transcripts (the existing agent fixtures plus new
vim, less, pico, and OpenCode captures) under four resize schedules. Point
ORCA_OLD_SERIALIZE_ADDON at a previous patched build to also check byte
identity when no line is wider than the grid, and that no checkpoint regresses.

* test(terminal): build the differential serialize baseline from any git ref

config/scripts/build-serialize-addon-at-ref.mjs reverse-applies the patch that
produced the installed @xterm/addon-serialize dist, applies the ref's patch,
and verifies each step against the patches' blob hashes, so the fuzz can use
origin/main (or any fix commit) as its baseline without a second install.
ORCA_NEW_SERIALIZE_ADDON swaps in a built dist for the build under test, and a
seed-pinned test replays the nine I3 regressions found against origin/main.

* fix(terminal): serialize a clipped wide glyph as a width-1 blank

The stand-in for a wide glyph clipped by a column shrink came from getNullCell(),
whose width is 0. _nextCell skipped it as a wide trailer, and the row-end wrap
check counted the width-0 _backgroundCell as content, so a soft wrap after the
clipped column was taken as natural and replayed one column early
(conpty seed 1149: `abcdefghi中WRAPPED` at 12 -> 10 cols replayed as
`abcdefghiR`/`APPED`). Blank the cell in place instead: width 1, no codepoint,
its own attributes, so it counts as one empty column and forces the wrap.

Differential sweep vs origin/main, 7000 cases per mode: I1 0 byte diffs, seed
1149 fixed; the remaining I3 regressions are the trailing background-row seeds.

* test(terminal): neutral paths in the serialize fixtures and usage comment

The OpenCode transcript carried this machine's lane paths in its footer; replace
them with same-length neutral paths so the recorded cursor layout is unchanged.

* fix(terminal): keep trailing background-only rows when serializing

Without scrollback, _serializeString trims rows after the last content cursor.
A row made only of background-colored blanks emits its erase in _rowEnd but
never moved that cursor, so two or more such rows at the bottom were dropped
(4x3 `r1\r\n\e[48;5;157m\e[J\e[0m\e[3;1H` replayed with the last row blank).
Track the erase separately and extend the kept rows to it, except when the
cursor is wrap-pending: relative moves back from those rows cannot re-create
that state, and doing so regressed normal 508, alt 1425/6647, conpty 4699.

Harness: I1 now exempts checkpoints with a background row after the last text
row, the one place this fix changes bytes on purpose (scope helpers move to
serialize-grid-variant-scope.ts); conpty seed 5 leaves the pinned pre-existing
list. Sweep vs origin/main, 7000 cases per mode: I1 0, I3 regressions 0;
fixed/both-fail normal 967/3914, alt 2628/8316, conpty 6657/2937 (was
323/4558, 2296/8648, 5466/4128).

* test(terminal): narrow the I1 carve-out to where the serializer keeps background rows

Trailing background-only rows change bytes only when the serialized range has no
scrollback (the trimming path) and the cursor is not wrap-pending; checkpoints with
scrollback or a wrap-pending cursor are held to byte identity again. The 7000-per-mode
sweep against origin/main stays at I1 0 and I3 0.

* test(native-chat): pin that screen scrapers ignore kept background rows

The serializer now keeps trailing background-only rows, so a painted TUI's screen
read as text ends in \r\n\x1b[NX rows. Serialize the same frame with and without
them and check the Claude option scrape, the empty-prompt check and the fork
transcript read the same thing.

* test(terminal): read xterm core internals through a checked parser, not Reflect.get

The fuzz oracle reached xterm's private _core with Reflect.get, which the
low-evidence gate rejects. Narrow _core, writeSync and the DECSTBM bounds with
in/typeof checks into one named XtermCoreInternals shape instead.

* test(terminal): keep captured serialize transcripts byte-exact on Windows checkouts
2026-09-25 01:18:14 -04:00
Jinwoo Hong a05649de91 fix(terminal): prove an idle Git Bash prompt through its bin launcher (#22752)
* fix(terminal): prove an idle Git Bash prompt through its bin launcher

Git for Windows' bin\bash.exe is a launcher that runs usr\bin\bash.exe as a
child and waits, so an idle Git Bash pane's job always holds two pids and the
Windows shell proof never confirmed it. Accept exactly the launcher plus its
direct bash.exe child, checked against the identity process table.

* test(terminal): wait for the Git Bash prompt before asserting the hand-off job

* fix(terminal): prove a Git Bash prompt as one unbranched MSYS bash chain

Orca launches Git Bash as bin\bash.exe -c "chcp.com ...; exec \"$BASH\" ... -i",
and each MSYS exec leaves its pre-exec process alive as a stub, so an idle
pane's job is launcher -> stub -> interactive bash. Accept any job that is one
parent-to-child chain rooted at the launcher whose every later member is
bash.exe, instead of a fixed two-process shape.

* ci: register the Git Bash shell-proof win32 test in the package-test list

* fix(terminal): read the spawned shell as a path, and keep one shell map

A spawned shell path with a space (/Users/John Doe/bin/zsh) was split as a
command line, so the POSIX proof compared against "john" and never
confirmed. Local panes now keep only the spawned shell path and derive the
name from it; the Git Bash chain walk drops guards the member check already
covers.
2026-09-25 01:00:36 -04:00
Jinjing b419b3183e test: remove redundant mobile and GitLab checks (#22748) 2026-09-24 20:45:15 -07:00
Neil 7ea01279cd feat(search): bundle ripgrep for local, WSL, and SSH search (#22396)
* feat(search): bundle ripgrep for local, WSL, and SSH search

Ship @vscode/ripgrep-universal's prebuilt rg for all six relay platforms in
every desktop artifact. Local and WSL searches spawn the bundled binary and
drop the git ls-files / git grep fallbacks; SSH deploys upload the remote's
binary once per ripgrep version and the relay prefers it over PATH rg.

* fix(search): address bundled ripgrep review findings

- Key the SSH ripgrep cache on the binary's content hash; a package bump is the only update step
- glibc verifier: read arch tokens below the slice root and accept static ELFs (arm64 release blocker)
- Ship ripgrep/PCRE2/musl license notices; bundle rg with orcad
- Packaged builds never spawn a bare rg; report fd pressure as transient
- SSH: install rg before sweep/GC, size-validate installs, back off instead of disabling on launch failure
- Scope Dependabot to @vscode/ripgrep-universal; revert unrelated lockfile churn

* chore(search): drop bundled-ripgrep reference doc; assert full packaging layout parity

* refactor(search): one entry point for spawning the bundled ripgrep

Local Quick Open, Quick Open path search, the Explorer name filter, and
runtime text search each repeated the same three steps: resolve the bundled
command, spread in the WSL distro, spread in the WSL shell expression. Fold
that into spawnBundledRipgrep so one place owns the rule that a bare 'rg'
must never reach spawn, and simplify the resolver's command/packaged checks.

Restore the AGENTS.md ripgrep rule dropped alongside its reference doc in
63f4dac, and note why the relay's availability probe may spawn a bare 'rg'.

No behaviour change; verified by the existing suites plus a new test that
pins the local, WSL-routed, and distro-routed-but-Windows-output cases.

* refactor(search): drop the local install-ripgrep path; enforce the rg rule

Bundling rg removed the local git/readdir fallback, so nothing can produce
the "install ripgrep on the host running the Quick Open scan" guidance any
more -- only a remote host an upload never reached still reaches the capped
listing. Drop the host parameter, the renderer's local branch and its
translation key, and the relay wrapper that existed only to pass 'remote'.

Add a ratchet test for bare 'rg' spawns, since the AGENTS.md rule alone had
nothing enforcing it. Its one allowlist entry is the relay's PATH probe,
which asks about PATH by definition. Verified the guard catches a planted
offender rather than passing vacuously.

Also stop chaining the remote cleanup sweep behind the ripgrep upload: on a
cold host that is a multi-MB transfer, and stale upload stages and
superseded version dirs were left on the remote for its whole duration. The
two touch different trees, so they now run concurrently.

* test(ssh): pin that the cleanup sweep does not wait on the ripgrep upload

* fix(search): derive rg spawn types instead of importing node:child_process

A type-only import still counts against the child_process ratchet, whose pin
and allowlist only ever shrink. Derive both types from wslAwareSpawn instead.

* fix(search): surface an unreachable WSL workspace instead of an empty result

Inside `bash -c`, a failed `cd` exits 1 -- the same code ripgrep uses for "no
matches" -- so a WSL workspace whose directory had gone away reported an empty
listing as a successful scan. main did not have this hole: checkRgAvailable ran
the same `cd` wrapper first and settled on `code === 0`, diverting to the git
fallback that this PR deletes. The WSL wrapper now takes an optional
cwdFailureExitCode; rg passes 97, and all four close handlers reject with a
clear error before the unavailable check can blame the install.

Also from review:
- Bound the fire-and-forget ripgrep upload with deploySignal. The controller
  aborts only on the deploy timeout, never on success, so this cancels a
  still-running upload when the deploy gives up.
- Run the stale-stage sweep before the installed check rather than inside its
  else branch. Once rg was installed every later deploy took the PRESENT path,
  so a stage orphaned by a dropped connection was never collected again.
- Note in orcad-remote-deploy.ts why wiring it up needs ripgrep work first:
  build-orcad.mjs copies only the build host's rg, and orcad reports
  isPackaged() === true, so a remote of another platform would find nothing.

ssh-relay-deploy.test.ts sat at the max-lines cap, so any edit to it failed the
gate. Split the four Windows named-pipe deploys into their own file (926 -> 737
+ 333); both are now well clear of it.

* fix(search): name the unreachable root in every handler, not three of four

Round-two review caught that the missing-cwd branch in scanRipgrepPaths sat
AFTER isRipgrepUnavailableExit, which classifies any code above 2 as a broken
install -- so for exit 97 it was dead code and Quick Open still told the user to
reinstall Orca. Reordered; all four handlers now check it first.

Also from review:
- A vanished workspace makes spawn fail with ENOENT, which read as a damaged
  install on every local path. Confirm the cwd with isRipgrepSpawnCwdUsable --
  the guard the relay already applies -- before blaming the binary. The async
  continuation re-checks `resolved`, because finish() drops its argument once
  settled and the rejected promise would otherwise go unhandled.
- bundledRipgrepCommand returned a bare 'rg' for an arch outside the bundled
  set, bypassing the guard that exists so Windows cannot resolve a bare name
  against the repo cwd. A packaged app now always names an absolute path.

Drop ci-shards/unit-assignment.json, a 9,425-line CI artifact swept in from
reproducing a shard locally, and gitignore the directory that produced it.

The "rg genuinely cannot start" test pointed at a synthetic /repo, which the
new guard correctly reports as unreachable; it now resolves to a real root so
it still tests what its name says.

* fix(search): let the error handler own the spawn-failure verdict

A failed spawn emits 'error' and THEN 'close' with a negative code. The cwd
check added in the error handler did not settle, so the close handler settled
first -- synchronously, with the reinstall message -- and won the race every
time. The branch was not merely flaky, it was unreachable in all four handlers:
it is guarded by pid === undefined, which is exactly the case that always
produces a following close(code < 0). Verified against a real spawn: 3/3 runs
give error(ENOENT) -> close(-2). The error handler now detaches 'close' before
the probe, so it owns the outcome.

The probe also had no rejection handler, so a probe that rejected left the
search unsettled forever -- a hang, not just a wrong message. It now falls back
to the prior verdict rather than inventing one.

Tests: filesystem-search-rg-timeout and orca-runtime-files-search already cover
error-first and close-first, but against synthetic roots that the new guard
correctly calls unreachable; they now resolve to a real root, keeping each
test's stated intent. Added a Quick Open case for the vanished-workspace path
and confirmed it fails with the old ordering.

* test(search): cover exit code 97 in all four ripgrep close handlers

Round-four review found the missing-cwd branch had zero handler coverage: no
test anywhere emitted close(97), only -2/0/1/2/127. Ordering was correct, but
guarded by source-line order alone -- and that exact ordering was wrong in
three of four handlers two commits ago. Each suite now drives close(97) through
its real handler and expects the unreachable-root message.

Verified the tests earn their place: neutering the missing-cwd check fails
exactly four tests, one per handler.

Also drop a Reflect.get the anti-slop gate rejects, in favour of `in` narrowing.

* docs(search): stop claiming the close handler always wins the race

The previous commit asserted close "would beat this threadpool round-trip every
time", from an n=3 sample that measured event ordering -- which was never in
dispute -- rather than probe-vs-close. Two later measurements disagree with each
other: 50/50 close-first here, 30/50 probe-first in review. Either way it is a
race on a sub-millisecond margin, and the detach is what makes the verdict
deterministic.

Why this wording matters: "close wins every time" is an argument for deleting
the detach as a guard against an impossible race. No test would catch that --
the suites emit error and close in the same synchronous tick.

* chore(search): ship the jemalloc and libunwind notices the Linux rg needs

The statically linked Linux builds carry jemalloc (BSD-2-Clause) and LLVM
libunwind (Apache-2.0 WITH LLVM-exception) in addition to PCRE2 and musl, and
both require their notice on binary redistribution. Confirmed with `strings`:
their symbols are present in linux-x64 and linux-arm64 and absent from the
darwin and win32 builds. Texts taken from the upstream canonical sources.

extraResources already copies the whole licenses directory, so these ship
without a packaging change.

* fix(relay): stop spawning a bare rg, name unreachable roots, collect old builds

Three gaps the reviews surfaced on the remote side, all pre-existing on main.

Bare `rg` on Windows remotes. Both relay spawn sites pass the user's repo as
cwd, and CreateProcessW searches the cwd before PATH -- the same hijack the
desktop side already fixes. The relay now walks PATH itself and spawns an
absolute rg.exe, skipping relative PATH entries because those resolve against
the cwd. No rg on PATH yields null, which callers treat as "ripgrep
unavailable" rather than handing spawn a bare name. POSIX keeps the bare name:
execvp never consults the cwd, so there is nothing to resolve and nothing to
gain. With the last probe converted, the bare-spawn ratchet allowlist is empty.

Empty results for an unreachable root. settleLaunchFailure resolved an empty,
successful-looking scan when the root was gone but PATH rg existed, and the
git/readdir chain never engaged because it only triggers on
RipgrepUnavailableError. Both relay paths now reject naming the root, matching
local workspaces. Missing-rg keeps precedence over a missing root, because only
that verdict engages the fallback chain -- two tests pinned that deliberately
and it would have been wrong to flip it.

Unbounded ~/.orca-remote/ripgrep/. Nothing collected this tree; the relay's
version GC only matches `relay-*`, so every rg bump left another ~5 MB per host
forever. The probe command now also drops sibling builds older than two weeks,
sparing the current one and live upload stages, on POSIX and PowerShell alike.
Two weeks because a client pinned to an older build may still be using it; the
cost of collecting one early is that client re-uploading once.

* fix(relay): probe the rg that failed, and close the drive-relative PATH hole

Five review findings against the previous commit, all reproduced first.

The launch-failure classifier probed PATH rg, but the spawn that failed was the
bundled binary. On the normal remote setup -- no rg on PATH, which is why Orca
uploads one -- the probe failed and a moved workspace was reported as a missing
ripgrep, telling the user to install what Orca already ships. So the fix was
inert on exactly the hosts the uploader exists for. It now takes a candidate
list and asks the binary that actually failed first, then PATH.

path.win32.isAbsolute accepts `\tools` and `/tools`: rooted, but carrying no
drive, so they resolve against whatever drive the process is on. The probe
would have validated one against the relay's drive while the spawn, running
with the user's repo as cwd, resolved it against the repo's -- the same
cwd-dependence this lookup removes, narrowed from directory to drive. A real
drive letter or UNC root is now required.

probeRipgrepVersion had lost the timeout's kill in the rewrite, leaking a live
process and a ref'd handle per launch failure -- for a hang, which is the very
case the bundled-rg back-off exists for. It also spawned without windowsHide,
which would flash a console; fixing that made an allowlist entry stale, so the
entry is gone and the pin ratchets down 63 -> 62.

`windowsPathRipgrep ??= …` never memoised a miss, because null is nullish. The
caching was inverted against cost: a hit stops at the first directory, a miss
stats every one, and only the miss was repeated -- per spawn.

The bare-spawn ratchet claimed "nothing in production spawns a bare rg", which
is false on POSIX. It now also matches PATH_RIPGREP_COMMAND at a spawn site,
and the comment states plainly what a textual guard cannot see: the POSIX bare
name reaches spawn as a parameter, and is safe because execvp ignores the cwd.

The drive-rooted predicate is tested directly rather than through the
filesystem -- a temp dir on a POSIX CI host has no drive letter to exercise
win32 semantics with, so the filesystem test could never have caught this.

* test(mobile): repin the session closure past #22452's two shared modules

Merging main brought the closure to 4220 against a pin of 4218. The two extra
modules are `src/shared/agent-turn-outcome.ts` and `src/shared/main-agent-status.ts`
from #22452, which the status projection this route already reaches import.
That change was src/shared-only, so the mobile job never ran on it -- the same
way the structured tool line slipped past, as the ledger above already records.

Repinned here because this PR's file set is what next made the job run, not
because this PR reaches either module. Verified: of the 28 source files this
branch changes, none appear anywhere in the route's 4220-module closure.

* fix(search): preserve remote binaries and complete runtime packaging

* test(relay): pin the probe's env now that it inherits the relay's PATH

8d6759a threaded the relay env into probeRipgrepVersion -- correctly, since the
probe decides whether a launch failure was the binary or the root and so has to
resolve the same rg the failed spawn would have. It left the assertion that
pins the probe's spawn arguments behind, which is what CI caught.

Asserting buildRelayCommandEnv() rather than loosening the match to any object:
under process.env the probe could resolve a different rg, or none, which is the
regression the change exists to prevent.

* feat(ssh): collect remote ripgrep builds by reference, not by age

Nothing collected `~/.orca-remote/ripgrep/`: the version GC matches only
`relay-*`, so every change to the shipped bytes left another ~5 MB on every SSH
host, permanently. The age window this replaces was the wrong instrument --
a directory's mtime is when it was written, not when it was last used, so it
cannot tell a superseded build from the one a live relay was launched against.
Deleting the latter is not graceful degradation: without a PATH ripgrep remote
text search rejects outright, and listing drops to the capped walk this PR
exists to remove.

So the question is reference. Each relay directory now records the build it
runs against in `.ripgrep-ref`, written only once that binary is confirmed
present, and the GC collects a build only when no installation names it.

The discipline is ssh-relay-native-deps-cache-gc.ts': anything the pass cannot
account for blocks the whole pass. A relay directory with no readable marker is
an older Orca's, possibly running right now against a binary it never recorded,
so the pass declines rather than guessing. Those directories are removed by the
version GC in time, which is what makes their builds collectable -- hence
running after it, not beside it. Deletion is the same tombstone, recheck under
the rename, then remove, so a deploy that takes a reference mid-pass gets its
tree restored. Windows has no pass yet, matching the native-deps cache's gate.

One test note: the first version of the "unaccountable blocks the pass" test
passed against a deliberately broken guard, because the tombstone recheck
masked its absence. The test now puts a readable recheck behind an unreadable
first scan, which is the only shape that fails when that guard is removed.

Recording the reference lives inside ensureRemoteBundledRipgrep rather than at
the call site: it is the same concern, and it keeps the deploy's ripgrep
surface to one call for the tests that mock it to protect their exec queues.

* feat(ssh): collect Windows remotes too, and ship the Rust crate notices

Three items previously left documented-but-open.

Windows remote accumulation. The cache GC was POSIX-gated, so the leak did not
go away -- it moved to the platform with the larger binary (rg.exe is 5.43 MB on
win32-x64, against 4.77 MB for linux-arm64). The PowerShell dialect now does the
same reference scan: entries and references carry token prefixes, because
PowerShell writes every uncaptured value to stdout and an untokenised listing
would feed Remove-Item whatever a cmdlet happened to emit.

Verified on a real Windows host rather than a mock: the listing emits its
ENTRY/LIST_OK tokens, a relay directory carrying a marker yields REF <entry>,
and a relay directory without one yields REFS_ERR -- the safety path, on the
real interpreter.

Rust crate notices. The crate set was read out of the shipped binary's symbols
and the licence identifiers taken from crates.io rather than assumed. Where a
crate offers the Unlicense, Orca elects it: a public-domain dedication carries
no notice obligation, and that covers eight of them. The four that do not offer
it get their MIT text reproduced. encoding_rs carries a BSD-3-Clause notice for
its WHATWG-derived encoding data that is joined by AND, not OR, so electing MIT
does not discharge it.

Release-only validation, corrected rather than repeated. Linux AppImage/deb/rpm
already runs in CI's package job on every PR, and Windows signing was already
rehearsed on this branch. macOS notarization is the only item a release must
still exercise, and the exposure is narrow: notarization requires signatures on
Mach-O binaries, and of the six bundled builds only the two darwin ones are
Mach-O -- `file` reports ELF for linux and PE32+ for win32 -- so signIgnore
excludes only files the notary never asks about.

orcad-artifacts.test.ts caught the new notice file missing from the standalone
runtime's shipped list, which is exactly the gap that test exists to catch: a
notice committed to the repo but never actually shipped.

* fix(search): protect relay cache references and handle failed spawns

* fix(ripgrep): close review gaps and repair deployment fixtures

* test(mobile): refresh merged session module census

* fix(ssh): preserve ripgrep caches with empty legacy references

* test(mobile): assert bundle boundaries instead of global module count
2026-09-24 17:25:48 -07:00
Jinwoo Hong 9f7f406b57 test(mobile): repin the RPC recording corpus and session closure after #22392 (#22702)
* test(mobile): repin the RPC recording corpus to main after #22392

#22392 pinned baseline to a branch commit (2c2b84ce9e) that the squash left
unreachable, so the recording-pin ancestry job failed on main and every PR.
Repin to main's tip 80e0bee23b and re-record the whole corpus: all 787
goldens change only their baseline header, so no recorded behaviour moved.

* test(mobile): re-measure the session route closure on main after #22392

Main reads 4219, not 4217: #22301 added two src/shared modules to the route
without touching mobile/, so main was already two over when #22392 measured
its -2 against a branch base that lacked them.
2026-09-24 14:30:59 -04:00
Jinjing 5613c4fe71 fix(i18n): add missing translations for artifacts and browsing (#22697)
Adds translations for artifact publishing, remote browser features, SSH workspace routing, browser identity settings, and skills management across all supported languages (Spanish, French, Japanese, Korean, Chinese).
2026-09-24 11:18:34 -07:00
Brennan Benson 80e0bee23b fix(floating-workspace): keep agent launches from moving the main window's tab (#22603)
* fix(floating-workspace): keep agent launches from moving the main window's tab

Launching an agent from the floating workspace's "+" menu switched the main
window off whatever chat or editor tab it was showing and onto its terminals.

The main window's selection is supposed to move only for the worktree it is
showing: browser and editor tab creation, splits, moves and drops all check
`activeWorktreeId === worktreeId` before touching it. Two places did not:

- `launchAgentInNewTab` called `setActiveTabType('terminal')` without a
  worktree, which targets the active worktree whatever worktree the launch
  landed in.
- terminal `createTab` wrote the global `activeTabId` for a tab in any
  worktree.

Both now follow the store rule. The launch still selects its tab within its
own worktree, which is what the floating panel renders.

The floating titlebar button had side-stepped this with an `activate: false`
opt-out plus manual selection. That opt-out had no other caller and is removed;
the button now launches and focuses like every other entry point.

* fix(tabs): scope the remaining launch surface writes to the launch's worktree

Three more launch paths create a terminal tab and then call
`setActiveTabType('terminal')` without a worktree, which targets whatever
worktree is active when the call runs rather than the one the tab landed in:

- the paired-host agent launch, after the host's asynchronous create
- Session History resume, which can target a worktree the user is not viewing
  and activates it only afterwards
- sleeping-agent resume, which the activation gate runs after asynchronous
  readiness checks, by which time the user may have moved to another worktree

Each now names its worktree, like the local agent launch. The new tab still
lands selected when the user switches to that worktree.

* test(tabs): pin the paired-host launch scope in its existing web-runtime test

* test(tabs): type the left-worktree resume fixture instead of casting it

* refactor(tabs): require the worktree that setActiveTabType applies to

`setActiveTabType(type, worktreeId?)` quietly fell back to the active
worktree when the caller left the worktree out. A caller acting on a tab in
another worktree (the floating workspace, a background launch, a reveal that
lands after an async step) therefore retyped whatever the main window was
showing. The launch paths fixed earlier in this branch were instances of that;
54 other callers still relied on the fallback.

The worktree is now a required argument (nullable only for the no-active-
worktree case), so every caller states which worktree it means and a new
unscoped call fails to compile. Each call site passes the worktree of the tab
it acts on; where that is by construction the active worktree (shortcuts,
palette, tab strip), the result is unchanged. `activateTabAndFocusPane`
resolves the tab's owning worktree the same way `setActiveTab` does.

End-to-end helpers that drive the store directly pass the active worktree,
which keeps their previous behaviour.

* fix(floating-workspace): let the floating New Terminal activate its own tab

The floating "+" New Terminal created its tab with `activate: false` and then
selected it with `activateTab`, because creating an active tab used to write
the main window's selected tab even for another worktree. `createTab` now
activates a tab only within its own worktree's group unless that worktree is
the one on screen, so the workaround is no longer needed.

Creating the tab active also moves the floating workspace's remembered tab to
the new one; before, it stayed on the previously selected floating tab, which
auto-acknowledge reads to decide which floating agent the user is looking at.

* refactor(floating-workspace): route every floating New Terminal through one creator

The floating "+" New Terminal had stopped deferring activation, but Cmd+T with the
floating panel focused still went through a separate creator that created the tab
inactive and activated it by hand, which left the floating workspace's remembered tab
on the previous tab. Both now call createFloatingWorkspaceTerminalTab, which creates
the tab active in its own group and focuses it.

* docs(tabs): say why an unowned tab id keeps the on-screen worktree scope
2026-09-24 10:54:43 -07:00
Jinwoo Hong 060743d813 fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps (#22392)
* fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps

Frame pacing. The pane decodes each frame on a hidden layer and flips to it on
onLoad. While one frame decoded, every newer frame re-pointed that same hidden
layer, which cancels the in-flight load. On a phone that decodes a frame
slower than frames arrive (~10/s during page loads, menus, spinners), onLoad
never fired for any of them and the pane sat on an old frame until the page
went still. A decoding layer is now never re-pointed: only the newest frame is
held, and it takes the layer once the decode settles. A 1.5s watchdog frees a
layer whose decode never reports, and a frame the hidden layer already holds
(a blinking caret alternating two frames) flips at once, since an unchanged
source reloads nothing.

Double taps. When browser.mouseClick failed, the pane replayed the tap as
move/down/up. On a timeout the click is still queued on the host, so the
replay landed a second tap on whatever the first one opened. The replay now
runs only when the click definitely did not reach the host.

* test(mobile): re-record the corpus without the timed-out tap replay

The corpus certified the move/down/up replay after a transport-rejected
browser.mouseClick, which the commit before removes. Scoped like #22179:
baseline bumped by editing that one line, then --record.

788 files. Every changed line classified:
- `baseline`: 787 files (786 goldens + pilot-scenarios.json), nothing else.
- matrix-browser.pointer-click-browser.mouseclick-1.json: the
  transport-rejection and transport-rejection-no-message partitions of
  browser-pointer-click-fallback now send only browser.mouseClick#1. The
  refused partitions still replay, unchanged.

* test(mobile): move the session closure pin past #22452's main-agent-status modules

#22452 changed only src/shared, so its CI never ran the page-closure suite; main
now measures 4220 modules (1034 local) against a pin of 4218. This branch adds
nothing to the closure: its own count matches main's.

* docs(mobile): say a re-pointed decoding layer loses its onLoad, as measured on Android

* refactor(mobile): give the streamed browser pane's double buffer one owner

The frame pacing, decode watchdog, layer flip and reset were spread over three
hooks and a helper module, wired back through the stream hook and the pane.
They now live in one plain pacer (browser-frame-pacer.ts) with one timer, and
the pane binds each layer's View/Image straight to it.

Behaviour fixed on the way, each with a failing test first:
- A slow last frame with nothing newer queued was abandoned by the watchdog and
  never shown. The decode deadline now only applies when a newer frame waits.
- Any pane re-render re-pointed both layers at the newest frame behind the
  pacer's back, so a blinking caret froze. The Image source prop is now only
  the mount-time frame; every later source write is the pacer's.
- A frame that failed to decode left its layer marked as holding it, so an
  identical frame flipped to an undecoded layer. Giving up on a decode now
  clears the layer's source.
- A native onLoad for a source the layer has since moved off could flip early.
  The flip now checks nativeEvent.source.uri, which Android and iOS Fabric both
  report as the raw source string; RN Web's own load event has none, so the web
  flips only through its decode probe.

The session closure pin drops by the two modules this removes.

* refactor(mobile): send the tap's mouseClick directly instead of through a flag

The delivery-unknown check was a mutable flag set inside the request callback.
The click now calls browser.mouseClick itself in a try/catch: a delivered click
returns, a delivery-unknown failure returns without replaying, and a refusal or
null result still replays as move/down/up. Same wire traffic; the corpus
certifies it unchanged.

* fix(mobile): never cut a streamed frame's decode short

The 1.5 s decode watchdog abandoned a slow decode whenever a newer frame was
queued and re-pointed its layer. On an Android emulator under load that is the
original freeze again: noise frames decode in 2-10 s, every abandon starts a
decode the next abandon cuts, Fresco reports the superseded loads (30 stale
onLoads in one run) and the pane showed 11 of 41 applied frames. Without it,
the same run flips every applied frame (16/16, no stale load), and a slow last
frame is shown in every cycle.

Nothing else needs it: with the layer never re-pointed mid-decode, native
answers every load with onLoad or onError, and the web probe's decode()
always settles. The pacer keeps one timer, for the interval.

* fix(mobile): track what each frame layer's Image holds, so no write goes unanswered

The pacer cleared a layer's source when it gave up on a decode (a reset
mid-decode, or a failed decode) while the native Image still held it. The
next identical frame was then written again, which is a native no-op on
Android (ReactImageView.setSource returns on equal sources) and iOS
(ImageShadowNode skips equal requests): no onLoad, no onError, and the pane
stayed frozen until the stream restarted. Returning from the background to
an unchanged page is enough to trigger it.

Each layer now records the source its Image holds and whether that source
has answered (loading, ready, failed). A layer is written only when it is
not loading and only with a different source, so every write gets exactly
one answer. A reset no longer abandons anything; the load under way still
answers for its layer. A frame the hidden layer already holds flips at once
if it decoded and is skipped if it failed.

The pane test's native model now treats a same-source write as a no-op and
answers each change once with the layer's current source; both new cases
(reset mid-decode then the same frame, failed decode then the same frame)
freeze on the previous head.

Also, per review: the pacer no longer touches busy or metadata. The stream
hook creates it and receives each frame as it goes on screen, so metadata
(and with it touch mapping) now follows the visible frame rather than one
still decoding.

* test(mobile): hold the pane test's AppState listener without a type assertion

* fix(mobile): never replay a tap the host answered

A fulfilled browser.mouseClick ran on the host, but a null result still
replayed it as move/down/up. The native bridge always answers { clicked },
while the external-Chromium provider returns agent-browser's `data` as is,
which can be null, so a right-click there was a double tap. Only a refusal
that is not delivery-unknown now replays.

* test(mobile): re-record the corpus for the answered-tap rule and rename its checkpoint

The commit before stops replaying a tap the host answered with a null
result. The seed's checkpoint was named clicked-by-fallback, which several
partitions no longer do, so it is renamed tap-settled in the same record.
Baseline bumped to 2c2b84ce9e by editing that line, then --record.

788 files, 841 changed lines each side. Every one classified:
- `baseline`: 787 goldens + pilot-scenarios.json.
- the checkpoint rename: pilot-scenarios.json (1), its id in
  browser-pointer-click-fallback.json (1) and the eleven partition ids in
  each of the four matrix-browser.pointer-click-*-1.json (44), plus
  `scenarioSha256` in those five goldens.
- behaviour, one checkpoint: the result-null partition in
  matrix-browser.pointer-click-browser.mouseclick-1.json now sends only
  browser.mouseClick#1 (sender and payloads drop move/down/up). The refused,
  method-not-found and result-absent partitions still replay, unchanged.

* fix(mobile): cover the frame layer remount, and trim the pacer's edges

Nothing tested the remount path: a fresh Image loads the source it mounted
with, so the pacer re-arms that load and puts back what the layer held.
The pane test's native model now mounts each host fresh (an Image loads its
mount source), and a new case remounts both Images mid-decode through a
zero-size layout: the visible layer keeps its frame and the stream goes on.
Deleting either the re-arm or the put-back fails it.

Also per review: drop the dead mountedUri guard in drain, return early from
attachImage when nothing is mounted, say that replace writes over a loading
layer, unexport the unused pacer types, cover the modifier bail in the tap's
comment, and move the frameUri state into the stream hook, which now returns
the source the layers mount with.
2026-09-24 13:37:51 -04:00
Brennan Benson 7a4f080086 revert: #18790 (orchestration incarnation reap fallback and bundled Freebuff agent) (#22601)
This reverts commit 0677271709.

#18790 was merged as one squash commit that carried two unrelated changes:
a process-incarnation fallback for reaping leaked orchestration worker
terminals, and an unannounced "Freebuff" third-party agent (catalog entry,
icon, locale strings, README rows). The Freebuff agent was never meant to
ship, so the whole PR is reverted; the reap fix should be re-submitted on
its own.

Until that re-land, a worker whose durable terminal handle goes stale is
again reported missing on release/stop instead of being re-found through
its process incarnation, so its terminal can leak on Remote Server.

The mobile session page closure pin moves 4218 -> 4219: the revert drops
the freebuff icon #22119 pinned (-1), and #22452 had already added two
src/shared modules without re-pinning (+2).
2026-09-23 21:39:28 -07:00
Jinwoo Hong f2ac9f29b2 fix(browser): let pixel capture hold its own page drawn, without the desktop window (#22534)
* fix(browser): let pixel capture hold its own page drawn, without the desktop window

Screenshots were the last browser commands that still borrowed the desktop
window: they took the per-page automation-visibility lease, which waits for
two desktop-window animation frames (capped at 2 s) and never arrives when the
window is minimized or throttled. Only pixel capture actually needs a page
drawn — input, scripts, layout, the accessibility tree and PDF all work on a
hidden page.

Capture now takes a main-owned paint hold: a synchronous, per-page ref-count
that tells the renderer one way (no reply awaited) to keep the page drawn and
keeps the desktop renderer unthrottled while held. Both Orca's full-page
capture and the agent-browser helper's screenshots take it in
cdp-screenshot.ts and retry on a bounded schedule until the page answers with
a frame; a CDP error fails fast.

Deleted: the queue's needsPaint lease, the executeJavaScript acquire path and
its two racing 2 s timeouts and late-token cleanup, the renderer's rAF wait and
window bridge, the capture commands' own leases, the fixed 300/500 ms settle
waits, and the global one-screenshot-at-a-time lock.

Rebased onto main after #22528 landed; content identical to the reviewed
branch head 7b390ed6a8.

* fix(browser): probe for a frame instead of repeating the full capture

Retrying a capture resent the caller's full request, so on an already
drawn tall page (a full-page capture takes ~0.5 s) the 250 ms retry
started a second full beyond-viewport capture while the first was still
running. Measured on Electron 43: any later request makes a held page
produce a frame, and that frame answers every pending capture with a
full, correct image. So the capture is sent once and 1x1 probes follow
until it answers; their results are ignored.

Also report a detached debugger as detached rather than destroyed, and
give the layout-metrics timeout its own "did not respond" message, since
that request doesn't need a drawn page.
2026-09-23 21:17:46 -04:00
Jinwoo Hong 37820f9683 feat(mobile): the page owns its safe area, like a native screen (OTA phase C follow-up) (#22570)
* feat(mobile): the page owns its safe area, like a native screen

The shell reserved both system-bar strips outside the WebView and painted
them bgBase, so every page screen showed a flat band above its header,
sheet scrims stopped short of the status bar, and the dock floated above
the gesture bar.

For a page that declares `safe-area-insets` in `ready.accepts`, the shell
now draws the WebView edge-to-edge and keeps only the keyboard strip off
it. `init` carries the insets the view sits under (bottom 0 while the
keyboard ends the view, top 0 under the update banner), and a move is
re-sent over the existing route-update `init`. An older page keeps the
reserved strips, since it has no reader for the insets.

On the page, a root layout (`app/_layout.web.tsx`) feeds those insets to
react-native-safe-area-context below ExpoRoot's env()-measuring provider.
It also replaces expo-router's DefaultNavigator, an all-edges SafeAreaView
that padded a second time.

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

* test(mobile-web): re-measure the page pins for the root layout

The page's route tree gained `./_layout.tsx` (its web sibling of the
native root), so every pin that counts the tree moved:

- Script sweep re-measured by building `routes.slice(0, n)` for each n.
  It reads 69 scripts at 16 routes, which matches the real build. The
  asset-ceiling crossing moves from 31 routes to 32.
- Route closures now enter through both layouts. `entryNames` gains
  `[dir]` because `app/_layout` and `app/h/_layout` share a name.
- Session closure pin 4216 -> 4219. The added modules are
  bridge-safe-area-insets, page-safe-area-provider and _layout.web.
- The web-overrides allowlist names `app/_layout.web.tsx`.

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

* fix(mobile): read safe-area ownership from the page-document state

Review fixes on the page-owns-safe-area change.

- Ownership is page-document state now. `page-ready` carries `accepts`
  beside `reports`, the patch sets `pageOwnsSafeArea` from
  `safe-area-insets`, and the session hook projects it like
  `backClaimed`. The screen's own per-session copy is gone.
- Insets moves re-send `init` only to a page that declared
  `safe-area-insets`. A page that took route updates but not insets was
  sent a useless `init` on every keyboard show and hide.
- The banner wrapper is gone. The root pads the status bar strip while
  the banner shows.
- The shell session defaults the insets inline, with no predicate that
  mutated its argument.
- The provider is folded into its single caller, `app/_layout.web.tsx`.
  The session closure pin reads 4218 (local 1032).
- The screen tests share their module mocks, and the safe-area cases
  move to a suite of their own: owned page, banner, iOS and Android
  keyboard, and an older page that gets no re-init.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 20:08:00 -04:00
Jinwoo Hong 63866c1e27 fix(mobile-web): page inputs lose the browser focus ring and hairlines draw one device pixel (OTA phase C follow-up) (#22569)
* fix(mobile-web): drop the UA focus ring from page text inputs

Chromium rings every focused text field (:focus-visible); no native TextInput
paints one. A zero-specificity rule in its own inline block beside the Expo
root reset removes it for every page input; buttons keep the browser's ring.

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

* fix(mobile-web): draw page hairlines one device pixel thick

react-native-web pins StyleSheet.hairlineWidth to 1 CSS px, three device
pixels on a 480 dpi phone; native draws one. A build shim replaces that one
assignment with React Native's own formula (roundToNearestPixel(0.4), else
1/ratio), so every page hairline matches native without touching components.

A rendered check at a real device scale (Playwright's emulated scale floors
borders to CSS px, which no phone does) measures both parity fixes.

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

* fix(mobile-web): apply the hairline shim to react-native-web's CommonJS build

The page's dependencies require react-native, so esbuild resolves every
importer to react-native-web's dist/cjs build, which the previous filter did
not match: the shipped bundle still assigned hairlineWidth=1. The filter now
matches both builds, the rendered check requires the package the way the page
does, and a builder test reads every hairlineWidth assignment in the bundle.

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

* fix(mobile-web): draw page hairlines at a width WebKit paints too

1/ratio is exactly one device pixel, and WebKit floors that to 0 and paints
nothing (0.3333px at a scale of 3), so the iOS shell would have lost every
hairline. The shim now uses native's device-pixel count plus half a pixel;
both engines floor a border to whole device pixels, so each paints what React
Native paints at ratios 1, 2, 3, 3.5 and 4, measured per engine. The rendered
parity check now runs in WebKit at a device scale of 3 as well as Chromium.

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

* test(mobile-web): check the parity style in the existing root-reset build

Drops a second full build that read one HTML string, plus two assertions
that tested the constant against itself.

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

* fix(mobile-web): round the page hairline up to the 1/64 px layout step

Half a pixel over native's count kept borders at one device pixel but made
the separators drawn as `height: StyleSheet.hairlineWidth` straddle two rows
at about half of all offsets. Both engines lay out in 1/64 CSS px, and WebKit
stores an exact 1/3 as 21/64 and paints nothing, so the width is now native's
device-pixel count over the ratio, rounded up to the next 1/64 (22/64 at 3).
The rendered check adds a separator at a 10.1 px offset in Chromium and WebKit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 19:48:45 -04:00
Jinwoo Hong 5b6a857e41 fix(mobile): route the bottom drawer's keyboard through the platform seam (OTA phase C follow-up) (#22556)
* fix(mobile): route the bottom drawer's keyboard through the platform seam

Fill-mode sheets called Keyboard.metrics() directly, which react-native-web
does not implement, so opening one on the page threw and the shell
re-downloaded the workspace. The drawer now reads useSoftKeyboard, whose
native half seeds from metrics() and carries the event duration, and whose
web half answers from the window (duration 0). The fill/content-sized seed
rule and resolveBottomDrawerKeyboardInset are unchanged. A census keeps
Keyboard.metrics/addListener inside the seam plus the tab-sheet hide wait.

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

* test(config): retire the drawer's exemption from the page keyboard census

The bottom drawer now reads the keyboard seam, so no module in the
source-control or review closures names react-native-web's Keyboard stub.
The census also flags Keyboard.metrics, which the stub lacks.

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

* refactor(mobile): give the drawer an imperative keyboard pair from the seam

The seam now exports subscribeSoftKeyboard and currentSoftKeyboardHeight
beside its hooks. The drawer's effect is back to its original shape with
only its Keyboard calls swapped for the pair, and useSoftKeyboard is back
to {height, visible} with no metrics() seed. Seeding every consumer opened
an iOS window between willHide and didHide where metrics() still reads
open. The web pair answers from visualViewport, so it stays silent inside
the shell and lifts sheets in a plain mobile browser.

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

* fix(mobile): start the web keyboard subscription from the current strip

A keyboard already covering the page when subscribeSoftKeyboard attached
never produced onHide when it closed, so the occlusion hook and a seeded
fill sheet stayed lifted. Outside the shell only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 19:46:52 -04:00
Jinwoo Hong 9cdbc0c128 fix(mobile): keep the shell's window insets out of the page WebView (OTA phase C follow-up) (#22549)
* fix(mobile-web): stop the page declaring viewport-fit=cover

The shell already pads the WebView out of the status and navigation bars. With
viewport-fit=cover, Android's edge-to-edge WebView still reports the window's
bar insets through env(safe-area-inset-*), which react-native-safe-area-context
on web reads, so every page-side SafeAreaView padded a full bar a second time.
Without it env() reads 0 and the shell's pad is the only one.

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

* fix(mobile): keep the shell's window insets out of the page WebView

WebView M144+ forwards the window's systemBars and displayCutout insets to
CSS env(safe-area-inset-*) for every WebView, and Chromium applies them
regardless of viewport-fit. The shell already pads the WebView out of both
bars, so every page-side SafeAreaView (expo-router's DefaultNavigator and the
session header) padded a bar a second time. M139+ likewise resizes the visual
viewport for ime(), which the shell has already done by shortening the WebView.

The WebView now sees those three types zeroed, per Android's "zeroing" approach
(not CONSUMED, so later changes still reach it). A listener replaces the
WebView's own onApplyWindowInsets, so the zeroed set is passed back into it.

iOS needs nothing: the WKWebView uses contentInsetAdjustmentBehavior = .never
inside the padded shell and reports zero insets.

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

* docs(mobile-web): say why the page declares no viewport-fit

The earlier comment claimed dropping viewport-fit=cover makes env() read 0 on
Android; Chromium's WebView applies the safe area regardless of viewport-fit.
The page simply never asks to extend under the bars, and the shell owns the
safe area.

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

* refactor(mobile): keep the page inset zeroing private to the shell view

The transformation has no honest JVM test (the builder runs as SDK 0 there
and drops every inset type), so it moves into MobileWebShellView.kt as
private members instead of standing alone. The listener comment now covers
both the P-R listener and the S+ onApplyWindowInsets path it replaces, and
the page document's comment says the env() zeroing is Android's; on iOS the
padded WKWebView reports none.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 17:13:23 -04:00
Jinwoo Hongandmaxidiazbattan 519bde81df feat(ipynb): render notebooks like a notebook, with seamless click-to-edit cells (#22519)
* feat(ipynb): parse ANSI SGR sequences in notebook output

Tracebacks and stream output carry terminal colour codes that the notebook
printed raw. Splits text into styled runs (16/256/truecolor, bold, italic,
underline) and drops non-SGR escapes via the shared stripper.

* feat(ipynb): render notebooks like a notebook, not a form

- Prose inherits the app UI font instead of a bare terminal font name, which
  Chromium could not resolve and fell back to Times (removes the now-dead
  resolveEditorFontFamilyOrInherit).
- Markdown cells render by default; double-click or Enter edits them in the
  same Monaco surface code cells use. Code cells activate on press so a
  collapsing neighbour cannot swallow the click, which also retires the
  root pointer-capture deactivation (Monaco blur already covers it).
- Code sits on its own tinted surface, the active cell gets a ring, and the
  editor sizes to its content so activating a cell no longer jumps.
- The always-on 8-button toolbar and native select become a hover/focus
  toolbar (move, delete, and a menu for insert and cell type); the Jupyter
  [n] prompt turns into the run button.
- Outputs show only the richest MIME representation, HTML renders in a
  script-less, no-network sandboxed frame sized to its content, and ANSI
  colours use the default terminal palettes.

Adopts the MarkdownPreviewBody reuse, richest-MIME selection, extra raster
MIME ranks, and CSP-sandboxed auto-height HTML frame from #18542.

Co-authored-by: maxidiazbattan <maxidiazbattan@gmail.com>

* chore(ipynb): drop the nbformat label and BETA badge from the notebook header

parseIpynb already rejects notebooks without a v4 cells array, so the label
carried no actionable information; the parsed nbformat field goes with it.
Removes those catalog keys and the stale lowercase code/markdown ones.

* fix(ipynb): keep cells pixel-stable when they switch to editing

The preview and the live editor disagreed on four things, measured over CDP:
- Font: the excerpt painted the bare "SF Mono" name (or --font-mono via its
  row class), while Monaco appended its own fallbacks and landed on Menlo.
  Both now use resolveEditorFontStack, the editor font plus the terminal
  fallback chain.
- Line height: 20px rows vs Monaco's 21px. Both read CODE_EXCERPT_LAYOUT.
- Gutter: a 48px line-number column plus 12px inset vs Monaco's 25px gutter.
  Notebook cells drop line numbers (the Jupyter and VS Code notebook
  default) and Monaco's decorations lane is the same 12px inset.
- Rows: colorized blank lines collapsed to 0px, and a trailing newline had
  no preview row. Rows are fixed-height and a trailing newline opens an
  empty last line, matching the Monaco model.

The [n] prompt and run icon now share one grid cell, so the hover swap keeps
the label's box and centre. The commented-line tint moves to a theme token.

* feat(ipynb): VS Code-style run gutter above a fixed execution count

Replaces the in-place [n]/play swap from 172b4f7937. The [n] count is always
shown and never moves; the run button sits above it in the same gutter
column, revealed on hover or focus from an invisible slot that keeps its
box. While a cell runs, the slot shows a spinner and the count reads [*].

* refactor(ipynb): give notebook code its own preview, keep MonacoCodeExcerpt unchanged

172b4f7937 bent the shared MonacoCodeExcerpt (used by PR comment code
context) to the notebook's layout, changing that surface's font, tracking
and highlight tint. The excerpt's rendering is now byte-identical to main;
only its colorizer is extracted as useMonacoColorizedLines. The notebook
renders its own rows from CODE_LAYOUT, the same metrics its Monaco editor
uses, so activation stays pixel-stable.

* fix(ipynb): open the cell editor with the caret where the user pressed

Monaco mounted with its caret at 1:1 and focus ran occurrence highlighting
on that first token, so clicking any line lit up every 'import'. The press
on the preview now maps to a model position (its rows mirror model lines)
and Monaco opens there before focusing; keyboard and markdown activation
open at the end. Occurrence highlighting stays on, as in VS Code notebooks.

* feat(ipynb): put the execution count on top and the run button below it

The [n] count now shares the first code line's box (the gutter mirrors the
code surface's border and padding, and the count is one line tall); the
run button slot sits beneath it and stays reserved while hidden.

* fix(editor): re-colorize code excerpts when the theme changes

Monaco's colorizer emits theme-specific token classes, so excerpts colorized
under one theme kept that theme's token colours after a switch (dark-theme
keywords rendered green on the light theme). Affects notebook previews and
PR comment code context.

* fix(ipynb): mount cell editors before paint so a click swaps in seamlessly

@monaco-editor/react waits on an async loader, shows "Loading..." and builds
the editor inside a display:none container. Every click on a cell therefore
painted a placeholder, then an editor laid out at 0x0 (one uncoloured line),
and the caret guess ran against the static preview, falling back to the end
of the cell whenever the press missed a text row (padding, gaps).

The cell editor is now created with monaco.editor.create in a layout effect:
sized, tokenized (restoring its view state marks the visible lines stable)
and placed before the first paint. Monaco hit-tests the press itself with
getTargetAtClientPoint, so the caret lands on the nearest position for text,
trailing whitespace, blank lines and padding; keyboard and markdown
activation open at the start. Indent guides stay off to match the preview.

Word and selection occurrence highlighting are off in cell editors, since
clicking a word lit up every other occurrence like a search.

Also drops wiring the rewrite made dead: the per-editor save shortcut (the
notebook's capture handler already owns Cmd+S), the callback refs, the
onSaveRequest prop, and the toolbar's positioning wrapper.

* fix(editor): follow OS theme flips when colorizing code excerpts

useMonacoColorizedLines read the theme once from settings, so a 'system'
theme flipping with the OS never re-colorized. It now uses
useDocumentDarkTheme, like the rest of the notebook; rendering is unchanged.

* fix(ipynb): let Escape close an open widget before it leaves the cell

The cell's Escape command had no context, so Escape with suggest, find or
parameter hints open closed the whole cell editor. It now only fires when
none of those widgets is visible.

* fix(ipynb): keep HTML output frames at an opaque origin

The output frame had allow-same-origin only so the viewer could measure its
content height. Notebook HTML is untrusted, so the frame is now sandbox=""
(opaque origin, no scripts) at a fixed height that fits a pandas head() or
describe() table and scrolls anything larger.

* refactor(fonts): move buildFontFamily into lib/monospace-font-family

lib/editor-font-zoom imported it from a terminal-pane component module. The
monospace fallback chain now lives in lib, and every importer, including the
terminal, points there. Pure move; the tests move with it.

* fix(editor): pass comma-separated editor font stacks through unchanged

resolveEditorFontStack quoted a value like "JetBrains Mono, monospace" as
one family name, which no font matches. A comma list is now used as-is;
only a single name gets the quoted fallback chain.

* chore(i18n): drop the Korean override for a removed notebook key

---------

Co-authored-by: maxidiazbattan <maxidiazbattan@gmail.com>
2026-09-23 15:07:30 -04:00
Jinwoo Hong 293c2508fc test(mobile): move the session closure pin past the structured tool-line module (#22430)
#22349 added `src/shared/structured-agent-session-tool-call-block.ts`, which the
projection and live turn the session route already reaches import. The PR was
src/shared-only, so its CI never ran the closure suite; main's pin stayed at 4215
while the closure measures 4216.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 02:34:45 -04:00
Neil 52a1e2875b feat(orchestration): accept Muse model and effort for supervised workers (#22383)
* feat(orchestration): accept Muse model and effort for supervised workers

`worker-start --agent muse` already launched, but `--model` was refused because
Muse had no session-option catalog. Add one that maps worker preferences to
`muse --model <id>` and `--reasoning-effort <level>`; it seeds no models, so
native-chat surfaces show no picker.

opencode stays without `--model`: the opencode 2 TUI (now shipped as
`opencode`) rejects the flag, so the refusal now tells callers to rely on the
agent's own config. Help, skill guide, and docs list valid `--agent` ids and
the agents that accept `--model`.

Refs #19823

* test(mobile): repin session route closure for the Muse option catalog
2026-09-22 22:20:35 -07:00
NeilandAdrien De oliveira ebed0964a2 feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness

Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage.

Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com>

Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com>

Co-authored-by: John Cusack <johncusackccm@gmail.com>

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>

* test(agents): cover Muse remote hook registration

* test(agents): cover Muse hook and source-control contracts

* test(agents): exclude Muse hook metadata from script mode check

* test(agents): keep Muse skill picker coverage stable

* test(ai-vault): include Muse in every-agent fixture

* test(mobile): repin Muse agent icon closure

* fix(muse): detect questions and approvals from structured Muse signals

Muse 1.3 fires no hook for request_user_input, so a pending question left
the pane "working". Its internal reminder subagents also post hooks with
their own session ids (even after Stop), which surfaced "tool failed" rows
and flipped finished panes back to working.

- Read pending questions from Muse's session log
  (user_input_prompt_requested/settled) via the existing transcript poll,
  now generalized from Codex subagents to Muse on main and relay.
- Drop child-session hooks (SubagentStart ids, or turn_id === session_id).
- Treat Notification permission_prompt as the approval wait; PermissionRequest
  also fires for auto-approved calls, so it only caches the approval card.
- Ignore Notification copy as the prompt; poll replays are not new prompts
  or turn boundaries.
- Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook.

* perf(muse): parse only question events from the session log

Most Muse session-log lines are large model/tool records. Filter raw lines
by the user_input_prompt_ marker before JSON.parse via an optional
readJsonlCursor line filter.

* fix(muse): unwrap batched log records and scope questions to the live turn

Review follow-ups: question events inside retained_frame batches were
skipped, and a question left open by a crash or interrupt stayed pending
for the pane's life. Share the history scanner's retained_frame unwrapper,
and only report a pending question whose run_id matches the hook turn_id.

* refactor(muse): drop type assertion in retained_frame unwrap

* fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy

---------

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>
2026-09-22 19:13:11 -07:00
Jinwoo Hong 0e6862cbcc fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials

Six failed-load screens share one Retry shape: re-dial a host that is not
connected, otherwise re-read. On the page the re-dial is inert
(`client-context.web.tsx:55`) and each screen's load already re-runs when the
shell's client reconnects, so in the disconnected state that Retry did
nothing at all. `connectionRetryAction` makes the decision once and answers
null when a re-dial is needed and none exists; agent history, the file
explorer root, the file preview, git history, the source-control status gate
and the diff review render no Retry for null.

The explorer's per-folder Retry keeps its control: it queues the folder, and
the queue drains on the next `connected` whoever brought it back.

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

* fix(mobile): the page offers no re-dial, so no header offers one

`forceReconnect` on the page was `() => Promise.resolve()`: the shell owns
the connection and nothing in the document can re-dial it. The host header's
Reconnect and the session header's "tap to retry" were wired to it and did
nothing there. The context member is now nullable and the page's provider
hands out null, so the compiler found every caller: both headers render no
reconnect affordance for null, and the session status keeps the verdict
label without promising a tap.

Native providers and the recording adapters still pass a function, so
nothing a phone renders changes. The session route's host-JSX parity hash
moves for the header's extra null check; the page test doubles that stubbed
the old inert re-dial now stub null.

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

* docs(mobile): the auth-failed banner cites the page's re-dial as null

Three comments and the banner's override reason still said the page's
`forceReconnect` was an inert `() => Promise.resolve()`, and cited
`client-context.web.tsx` lines the previous commit moved. They now say null
and point at the lines that hold it.

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

* test(mobile): the session closure gains the page's Retry decision

`connection-retry-action.ts` is the one module the Retry fix adds to the
session route's page closure, reached through the explorer, source control
and git history it docks. Measured on this head with all five generators run
first, and diffed against the pre-change closure: one local module added,
none removed.

Session route closure 4207 -> 4208 modules, local 1021 -> 1022.

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

* docs(mobile): the capability probe belongs on the page, and says why

The push fence excluded `runtime-capability-probe.ts` because the session
route and the host screen run it. The session half holds, and the probe
works there: `status.get` carries no client identity and makes no write, the
shell forwards it like any non-`native.` request, and the desktop's mobile
allowlist admits it. The host-screen half no longer does:
`codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which
the bundle carries and the page hands to the native screen.

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

* test(mobile): keep the session retry test's cast under its disable line

The formatter wrapped the cast onto the line after the disable comment,
which left it uncovered. The cast now sits on its own line directly below
the SAFETY note.

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

* test(mobile): the agent-history Retry test mocks the pathname the handoff reads

Main's page route handoff now subscribes to `usePathname` (#22300), and the
Retry suite this branch added mounts that handoff with an `expo-router` mock
that lacked it. Same one-line addition main made to the back-handoff suite.

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

* test(mobile): pin the reload each hidden page Retry relies on

Hiding a Retry on the page rests on the screen's load re-running when the
shell's client reconnects, because nothing on the page re-dials. Only the
explorer's folder drain pinned that. Each other site now has a case that
starts unreachable with no Retry and asserts the load goes out on the
client and state the reconnect delivers: agent history (status.get), file
preview (the preview read), diff review (the snapshot load), git history
(git.history) and source-control status (git.status, in the loaders suite
because the panel test mocks the state hook).

Each goes red when the `client`/`connState` dependencies it guards are
removed; for source control that is both `loadStatus` and the
`loadBranchCompare` it depends on.

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

* test(mobile): one import of the transport types in the source-control loaders test

CI's native code-quality audit denies the duplicate-import warning the reload pin added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:41:38 -04:00
Jinwoo Hong 7240368726 feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived

The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.

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

* feat(mobile): record why a hybrid shell update failed, on the device

A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.

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

* feat(mobile): forget a removed host's recorded update failures

Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.

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

* feat(mobile): show recorded update failures in Troubleshoot

A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.

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

* test(mobile): type the update-failure row doubles without casts

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

* fix(mobile): forget a host's update failures once a newer generation commits

The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.

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

* test(mobile): re-pin the session closure for the shared journal producer #22299 added

main at 9ece273056 measures 4212 modules against a pin of 4211: the native-chat fix
added src/shared/agent-session-journal-producer.ts, which three shared modules on the
session route import, and its PR touched nothing under mobile/ so the mobile job never
ran. Re-measured on this merged head and the joiner read off the closure list.

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

* fix(mobile): forget update failures on a download's activation, not a build-id match

The fetch reads the manifest again and commits the build that read named,
so a host that moved between the session's read and the fetch's committed
a build other than requestedBuildId, the forget was skipped, and the stale
"last update failed" line outlived the update that should clear it. The
activating state now says where it came from, download or cache, and the
forget follows a download's activation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 18:35:51 -04:00
Jinwoo Hong 11db2b9a7d feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key

The shell's page had no way to hear Android Back: every sheet inside it
early-returned on web, so the key popped the whole session route. Adds the
first negotiated shell-to-page frame kind alongside it.

- `back-claim`, page to shell, declared in `init.accepts`: the document is
  holding the key, or has let it go.
- `back`, shell to page, declared in `ready.accepts`: one press, dispatched to
  the newest consumer that takes it. A press nothing takes is handed back as a
  `navigate-back` rather than dropped.

Both are optional fields on frames the other side already reads, so an old
shell never hears a claim and an old page is never sent a press; each pops as
it does today. No protocol bump and no stream opcode.

`bridge-host.ts` was at its line cap, so the notify forwarder moves to
`bridge-host-notify.ts` unchanged.

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

* fix(mobile): Android Back closes the sheet on the page, not the screen

Inside the shell's page every sheet early-returned on web, so one press left
the session route with the sheet still open. The drawer, the right drawer and
the file-preview prompt now claim the key through one seam on both platforms:
`use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on
the shell's. All sixteen session sheets render through `MountedBottomDrawer`,
so the one claim there covers every one of them, and a census fails if a sheet
bypasses it.

`route-handoff.web.ts` claims while the page grew a stack of its own, and
hands the press back when it did not.

The shell takes the key off the navigator only while a claim is live: Android
gets a `hardwareBackPress` handler that returns the host's own answer, iOS
loses the stack's swipe-back. The claim is cleared on `document-started`, on a
remount, on a new `ready`, on anything that takes the generation off screen,
on the page's `close` and on dispose.

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

* test(config): a Back press closes a sheet on the real bundle

The unit suites reach both halves of the lane but never the two together on a
document a browser rendered. The render rig can now post a `back` frame, and
the drawer check opens the Filter sheet, reads the claim off the notify list,
sends one press and pins that the sheet closed with no `navigate-back` behind
it. Red without the drawer's claim: the claim never arrives.

Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is
shared with the native app and mounts under no page provider in a bare tree,
where `usePageBridgeClient` threw; the seam now reads the bridge through
`usePageBridgeClientIfPresent` and claims nothing without one.

Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route
handoff, `bridge-page-back.ts` through the envelope.

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

* refactor(mobile): mirror the Back seam's latest values from an effect

Three `ref.current = …` writes sat in render, which React replays and
discards. Each moves into a dependency-list-free effect declared ahead of the
registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already
uses for the same reason: the caller rebuilds the value every render, so there
is nothing to depend on, and `useRef` seeds the first mount. The registration
still keys on the claim alone, so a rebuilt handler re-registers nothing.

The web seam's test drops its two type assertions for a named fixture type.

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

* fix(mobile): the page says its Back claim again on every init

The claim was edge-triggered and the shell forgets on purpose: it drops the
claim answering every `ready`, and a host rebuilt under a live page — a client
swap through forceReconnect, which leaves the WebView mounted — starts with
none at all. A document still holding a sheet was then unknown to the shell,
and the next press popped the screen out from under it.

`init` is the shell saying it is here now, so the page answers each one with
the state rather than with a transition. Posted after the session has taken
the frame, so the gate reads that `init`'s own `accepts` and a shell that
never named the claim still hears nothing.

Nothing is said while nothing is held. Every `init` answering a `ready` comes
from a host that dropped the claim first, so it already holds false; the only
other one carries a rewritten route, where a stale true needs a `false` the
page posted to have never left, and a port that refused that frame refuses
this one too.

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

* fix(mobile): a rebuilt host keeps the session's Back claim

A host is rebuilt when the client under it changes, and the page document does
not move: the WebView stays mounted, the session id holds, and the page is
never told. The rebuilt host started with no claim and no `accepts`, so it
refused every press and the navigator popped the screen out from under an open
sheet. Two clients on the same generation leave the page nothing to refuse, so
nothing made it re-ask and re-assert.

What the page declared and what it is holding are facts about the session, the
way `sessionEstablished` already is. `createBridgeHostBack` takes them as a
seed, `readSessionBack()` hands them on, and the hook holds them stamped with
the session so a record left by one never seeds the next.

`dispose()` no longer reports the claim gone: a host retiring is not a
document ending, and that report was the thing taking the key off a live
sheet. Every reset path is unchanged and still has its own case — the page's
`ready`, its `close`, and the session's own store for `document-started`,
`remounted` and anything that takes the generation off screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 16:21:26 -04:00
Jinwoo Hong 564f135248 fix(mobile): an accessory Enter ends the field's editing session, and the page's Enter survives a composition (#22300)
* test(config): the page's live input never submits under an open composition

The emulator's page-only defect, in a browser: an Android soft keyboard keeps a
composition open over the word being typed, so the Enter keydown carries
`isComposing: true`, which is the condition react-native-web reads to skip
`onSubmitEditing` entirely. Nothing reaches the terminal and the field keeps the
text. The probe route now mounts `useTerminalLiveInputCommit` and the command
dock's own field props, so keys enter through the browser rather than through a
handle that calls the hook directly.

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

* style(config): format the live-input render check

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

* fix(mobile): the page's live input submits under an open composition

react-native-web's keydown handler withholds `onSubmitEditing` whenever the
Enter keydown reports a composition — `nativeEvent.isComposing`, or the Android
`keyCode` 229 that stands for it — which is a soft keyboard's normal state
mid-word. Nothing reached the terminal and the field kept the text. Native
Android's editor action has no such suppression, which is why only the page
showed it.

The field now also claims `beforeinput`/`insertLineBreak`, the browser's own
end-of-line signal. react-native-web cancels every keydown it does submit on,
so that event exists only in the cases it dropped, never twice; an IME still
choosing a candidate reports `insertCompositionText` and is left alone.

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

* test(mobile): list the live-input submit binding, and repin the session closure

The `.web.ts` sibling needed its row in `web-overrides.json`, whose check lists
exactly the overrides on disk. The session route's page closure moves with it:
the callback ref and the binding it resolves to are both local, and the native
sibling stays out, which is what the pair is for.

  modules        4207 -> 4209   (+2)
  local modules  1021 -> 1023   (+2)

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

* test(mobile): the key bar's Enter chip leaves the sent text in the live field

The device trace's variant (a), which is what shots/23 was: the chip emits no
DOM key event, so react-native-web's submit handling never runs and the
accessory hook is the only thing that could end the field's editing session.
With nothing held it takes the send-now branch, which flushes nothing and
writes neither the capture state nor the field, so the text the PTY already
echoed stays put and the next keystrokes append to it.

Not a page defect: the held-text fallback only holds a trailing non-ASCII run,
so ASCII leaves nothing held on native either. The unit case is on the shared
hook for that reason.

The probe route now models what the send actions do with 'allow-raw', so the
check can see a control sent twice or not at all.

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

* test(config): the buffered command field has the live field's Enter gate too

Three cases, one red. A plain Enter sends the draft and empties the field, which
is `beginBufferedTerminalDraftSend`'s doing and stays a guard. The key bar's
Enter chip in buffered mode is a plain terminal key: the accessory hook declines
at its live-handle guard, one carriage return goes out and the draft is
untouched, also a guard.

The red one is Enter under an open composition. This field reaches its send
through `onSubmitEditing` alone, so react-native-web's keydown gate swallows it
exactly as it did for the live field, and the draft neither goes out nor leaves
the field.

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

* fix(mobile): an accessory Enter ends the field's editing session, on both fields

Two defects, one rule: after a control that ends the line, the terminal owns
the echoed text and the field's editing session is over.

The key bar's Enter chip (device trace, variant (a)) emits no DOM key event, so
only the accessory hook could end that session. Its held-text branch does,
through the flush; with nothing held it took `send-now`, which flushed nothing
and wrote neither the capture state nor the field. Not page-only: the held-text
fallback holds a trailing non-ASCII run, so ASCII leaves nothing held on native
either. `send-now` now takes the same flush when the bytes end the line, and
still defers the send to its caller so exactly one return goes out.

The buffered command field had the live field's composition gate, because it
also reaches its send through `onSubmitEditing` alone. It binds the page's
line-break signal now too, which is why the seam is named for a terminal text
field rather than for the live input.

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

* test(mobile): the submit binding holds the handler its first render was given

Found by pullfrog on #22300. The binding refreshed its handler ref only when
the callback identity changed, so a caller memoizing on an empty dependency
list was bound once and never again. The buffered command field does exactly
that: its submit closes over handleSend, a per-render function whose guard
reads client and activeHandle, both null until effects supply them, so the
page's line-break submit could never pass that guard.

The probe route now carries the same two paths the dock has — a fresh
per-render function on the field's onSubmitEditing prop, and the memoized
closure on the binding — because the working prop path is what hid this.

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

* fix(mobile): the buffered field's page submit reaches a live handleSend

Found by pullfrog on #22300. `submitBufferedDraft` was memoized on an empty
dependency list, which froze the per-render `handleSend` it calls. That guard
reads `client`, `activeHandle` and `canSend`, none of which the first render
has, so the page's line-break submit could never pass it. The field's own
`onSubmitEditing` prop kept working, which is what hid it.

The handler is per-render now, and the binding refreshes its handler ref on
every commit rather than when the callback identity changes — the ref exists so
the listener always reaches the newest handler, and it should not rest on a
caller's memoization. That second half fixes nothing on its own: a `useCallback`
with `[]` returns one function object for the life of the component, so no ref
can find a newer closure behind it. The source census is what catches that, and
it is red against the frozen handler.

The probe route is back on the dock's shape, per-render on both submit paths,
with a note saying why a route that writes its own submit cannot catch this.

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

* test(mobile): the page's buffered submit reaches a handleSend that can send

The census beside this reads one spelling of a frozen handler. This reads the
behaviour: the send-actions hook is rendered first as a session is before its
effects land, with no client and no handle, then again as it is after, and the
listener the page's binding attached has to reach the second one. Asserted on
the params that reach the client, not on a call count.

Red with the `useCallback` restored, and red with a `useMemo` in its place,
which is the point of testing the behaviour rather than the spelling.

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

* test(mobile): declare the two mock arrays instead of asserting them

`[] as Array<T>` inside the hoisted factory was a type assertion with nothing to
explain: the arrays are built here, so a checked declaration says the same thing
and the quality gate has nothing to flag.

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

* refactor(mobile): the submit binding stops claiming a cure it does not have

The handler ref refreshed on every commit rather than on the callback identity,
and the docblock called that the reason the seam exists. It is not: a caller
that freezes its closure hands this hook one function object for the life of the
component, so no ref finds a newer one, and a caller that does not freeze it
changes identity every render and refreshes the dependency anyway. Measured both
ways. The dependency is back, and the prose says only what the code does.

The rule that does hold — a bound handler must not be frozen on an empty
dependency list — is stated where it is enforced, in the wiring census, with the
behavioural check named beside it.

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

* refactor(mobile): the submit binding's latest-ref drops its dependency list

React Doctor flags the dependency twice, once per bound field: both of the
dock's field submits are rebuilt every render, because the handleSend they read
is, so `[onSubmit]` is a new value every time and there is nothing to compare.
The tree's other latest-refs are written without a list for the same reason —
use-mobile-web-shell-bridge.ts:148 is the one this follows.

The comment says what the ref does, which is mirror the newest closure after
each commit so callers may pass per-render handlers. It claims nothing about a
caller that freezes one; that rule is still the wiring census's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 15:55:36 -04:00
Jinwoo Hong a8786c040d fix(mobile): the page never removes a host, and stops bundling push (#22283)
* fix(mobile): the page never removes a host

The page holds one host profile from `init.host` and no credential, so
`removeHost` on web resolved without doing anything and the screen reported
success for a host that was still paired. Its `.web` sibling refuses with a
typed error instead, and the auth-failed banner's Remove — the one surface
that opens the confirm — is absent on the page, because a control that can
only refuse should not be there.

Refusing is also the fence that keeps `push-registration.ts` out of the page
bundle: the native lifecycle file's import was that subsystem's only path
into a page route.

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

* test(mobile): drop the push families the page closure no longer reaches

`host-removal-lifecycle.web.ts` was `src/notifications`'s only path into a
page route, so the whole directory left the page bundle and the capability
probe left the C1 layout closure with it. The derived family set shrank by
two; the pin tables and their counts now match what the closure reaches.

The expo-notifications fence grows a second claim and loses a precondition
that had become false: `push-token.web.ts` and
`desktop-notification-channel.web.ts` are no longer in the bundle either, so
the fence is stated as the absence of the directory.

C1 20 families / 94 goldens, C2 68 / 257, C3 26 / 116, C5 25 / 125.
Session route closure 4211 -> 4207 modules.

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

* fix(mobile): the page's auth-failed banner offers no control it can honour

The banner is reachable on the page — the shell forwards the native client's
state verbatim (`bridge-host.ts:381`) and `auth-failed` is in the wire enum
(`bridge/bridge-envelope.ts:43`) — and the page can honour none of its three
actions. `forceReconnect` is `() => Promise.resolve()` there
(`client-context.web.tsx:55`, read through `host-client-hooks.ts:87`),
`/pair-scan` sits outside the page's route root of `app/h`
(`mobile-web-app-route-manifest.mjs:6`), and removal refuses. The previous
commit hid only Remove and claimed the other two still worked; they do not.

The whole action row moves into `AuthFailedBannerActions`, whose `.web`
sibling renders no control and one line naming the app. The sentence above it
is unchanged: re-pairing from the desktop is still what to do.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 12:49:25 -04:00
Jinwoo Hong 3bb9a4e261 fix(mobile): keep a painted frame under the page until its first paint (#22264)
* fix(mobile): keep a painted frame under the page until its first paint

The shell tore its own frame down the moment a generation was on screen
(`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws
nothing until its document paints. What showed for the whole of the page's boot
was the surface behind it with nothing on it: 1.42 s on a cached generation,
against a one-frame budget.

The page is the only thing that knows when it has a frame, so it says so. It
declares `painted` in `ready.reports` and posts the notify after the browser has
painted its first commit; the shell holds the same neutral frame it was already
painting while it opened the generation, then fades it out. The wait is bounded
by the declaration and never by a timer: a generation served by an older desktop
declares nothing and is uncovered on `ready`, which is what every shell did
before this.

iOS painted white rather than nothing: a WKWebView is opaque by default, so the
shell's own surface never showed through. It is now transparent, as the Android
view already was.

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

* fix(mobile): hold the cover through the compositor handover

The page reports the paint its own renderer made; putting that on the app's
surface costs another frame or two. A linear fade from the report left two
frames of bare surface between the two on an emulator, which is the hole the
cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity
across that handover: five reopens now show 0-21 ms of bare surface against
102-2043 ms on the build without it.

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

* fix(mobile): negotiate the paint report in both directions

The page posted `painted` whatever shell it met, and `notify` is a closed union:
every shell installed before this answered it with an error frame, once per
mount. The shell now advertises the name in `init.accepts` beside the param
clear and the client identity, and the page posts only when it was advertised.
The declaration in `ready.reports` stays unconditional, because it is an
optional field an older reader strips rather than a new opcode, and because the
first `ready` — the only one that matters for the first paint — is sent before
any `init` has arrived.

The accepts list moves into `bridge-init-frame.ts` beside the grants, which is
the module that builds the frame carrying it.

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

* test(mobile): read the cover's colour instead of asserting its shape

Two gate findings on the round-two head. The cover test reached the background
through a cast of the style prop; it now reads it through a checked narrowing,
so the test proves the shape it depends on rather than declaring it.

`use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args
gained `onPagePainted`: its harness is a literal, so a new required handler is a
missing property. The probe now counts paints and one case spends the counter,
which is what a handler wired only to satisfy a type would not do.

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

* refactor(mobile): move the cached-generation opening out of the reducer

`mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused-
update work and the paint handling both grew it. What comes out is one thing —
putting a generation already on disk on screen, and deciding whether this route
is one that bundle carries. It is the reducer's cache path and its refused-
update path both, and it was already three functions sitting together.

`step` goes into a module of its own because the two now share it; a copy in
each would be two spellings of one transition, and exporting it from either
would point the dependency the wrong way.

No behaviour moves: the reducer's table tests are unchanged and the page closure
is unchanged at 4,211, since neither new module is reachable from a page route.

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

* fix(mobile): drop the previous document's paint when a new one starts

A document that replaced a painted one inside the same mount inherited its
`pagePainted`, so the cover lifted before the replacement had drawn anything.
The native view already reports `loading`; the screen dropped it. It now
reaches the reducer as `document-started` and clears the page document state.

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

* test(mobile): make the declaration case call the frame policy

The case compared the name to itself and never called `shellPageFrame`, so it
passed for a policy that ignored the declaration entirely.

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

* fix(mobile): report the page's frame from the route screen, not the router

Every route screen is behind `import()`, so the wrapper above expo-router
commits with a suspense fallback while the chunk is still arriving. The paint
report hung there, which uncovered the shell's view over an empty body on a
cold chunk. It now hangs on the screen the manifest resolves, layouts excluded.

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

* fix(mobile): retire the readiness wait a replaced document armed

`document-started` cleared the page document state and left the flow alone, so
the previous document's readiness deadline passed the flow check, read
`pageReady` as false and failed a session whose replacement was still loading.
The flow moves with the document, for the reason `remounted` already moves it.

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

* fix(mobile): let a departing route screen take its paint report back

The report waits two frames, and nothing cancelled the second one, so a screen
unmounted in between still told the shell to uncover. The reporter now answers
with a take-back the wrapper returns as its cleanup, and the once-per-document
latch frees only when a report was cancelled before it landed.

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

* fix(mobile): let the screen that arrived take over a frame still owed

A screen committing inside the two frames an earlier one was owed found the
latch taken and reported nothing; the earlier screen then freed that latch on
its way out and nobody was left to lift the cover. The newest commit now
supersedes the pending report, and only a posted one spends the latch.

Covers the redirect window with a render check against the pr route, whose
target chunk is held open while the document sits on the hub's fallback.

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

* test(mobile): make the take-over case turn on the take-over

The case cancelled the first screen's frame through the cleanup path, so it
passed with the take-over deleted. It now leaves that screen mounted and reads
the clock: the frame after the replacement commits is the replacement's first,
not the one the screen behind it was still owed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 11:43:26 -04:00
Jinwoo Hong 9da7f7edfc test(config): the session render rig waits for the reads it asserts (#22269)
The session route issues worktree.show, session.tabs.list and terminal.list from
effects that run after the commit painting 'Terminal', so reading
__orcaRenderCheckRequests where openRoute resolves is a snapshot taken before the
burst. Under a 20x CPU throttle none of the three is in it, which is the shape
the loaded CI job hit twice. waitForRecordedRequests polls the double's own log
under a 30s bound and names what never arrived.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 09:49:28 -04:00
OrcaWinandm4air ba742a86bb fix(linux): release orphaned processes when their owner exits (#22247)
* fix(linux): release orphaned processes when their owner exits

* fix(linux): handle inhibitor errors until streams close

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-22 05:10:03 -07:00
Jinwoo Hong 0ab2ba3480 fix(mobile): the page stops writing, importing and requesting what it cannot use (#22241)
* fix(mobile): the page keeps no host app-version record

`host-status-gates.ts` runs above every page route, and a readable
`status.get` had it write `orca:host-app-version:v1:<hostId>` through
`host-app-version-store.ts`. Inside the page AsyncStorage is the bridge's
adapter and that key is not one `page-storage-keys.ts` hands a route, so
every mount posted a write the shell refused and logged as
`storage-write-dropped`.

Not admitted through the storage seam, because the page never reads it
back: the record's only reader is the native troubleshoot screen's
`native-diagnostics-operations.ts`, which is not in the page's bundle. A
`.web` sibling keeps no record instead. The bounds check moves to
`host-app-version.ts` so both hosts read a reported version the same way.

The session render check now collects warnings as well as errors and
answers `status.get`, which is what arms the write: the other cases'
double answers no RPC, so the drop needed a reply rather than a control.

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

* fix(mobile): the page does not import expo-notifications

`DevicePushTokenAutoRegistration.fx` runs at import: it adds a push-token
listener React Native Web answers with a warning, and it reads the
persisted server registration out of `window.localStorage` behind a
`typeof localStorage === 'undefined'` guard. The Android shell's WebView
has DOM storage off, where `window.localStorage` is `null` rather than
undefined, so the guard passed and the read raised "Cannot read
properties of null (reading 'getItem')" at error level on every page
load.

Two modules imported the package — `push-token.ts` and
`desktop-notification-channel.ts`, both reached through
`push-registration.ts`, which the host layout pulls in via the host
screen's remove action. Both get a `.web` sibling. The page holds no
device push token and creates no Android channel; push registration
needs a token the shell owns and a gateway the page has no client for.

Every call in those two files was already inert on web, so a page that
imports one behaves correctly and still loads the package: the closure
check beside them is what keeps a third importer out. The session render
check adds the device's own shape — `localStorage` reading `null` — and
reds on the error the emulator saw.

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

* fix(mobile): the page declares an icon, so no browser asks for one

With none declared a browser asks the origin for /favicon.ico on its
own, and the shell's asset server answers 403 because the path is in no
manifest — which the emulator run saw repeatedly. The document now
carries `<link rel="icon" href="data:," />`, a browser's own way of
being told there is no icon. An empty data URI rather than an asset: the
page is a WebView document with no tab to put an icon in, and the
bundle's images are content-hashed route assets whose names change with
their bytes. `img-src 'self' data: https:` already admits the scheme.

Two assertions, because each is blind where the other sees. The build
check reads the document and runs everywhere. The session render check
reads the request, which only a full Chrome makes —
`ORCA_MOBILE_WEB_RENDER_BROWSER`, what CI resolves — and reads it off the
server's own log: a favicon fetch comes from the browser process rather
than the page, so Playwright's request events never report one. It also
settles on network idle first, because the fetch comes after the text the
route waited on.

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

* docs(mobile): trim the page-noise comments to the bar

Comment-only. The three `.web` siblings, the document's icon line and
the three override reasons each said their cause once and then said it
again; each now states what the page keeps and why, once.

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

* test(mobile): re-pin the session closure after expo-notifications left

Measured on this head with all five generators run first, against a
scratch worktree detached at the base, which reads the committed pin
exactly: 4271 modules and 1023 local.

    modules        4271 -> 4210   (-61)
    local modules  1023 -> 1024   (+1)

65 modules leave and 4 join. 62 of the 65 are vendored: expo-notifications'
own 55, and expo-application, badgin, abort-controller and event-target-shim
behind them. The other three are the native files the `.web` siblings
replace, so the siblings cost the local count nothing and its +1 is
`host-app-version.ts`, the one new module.

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

* docs(mobile): count the packages the closure note names

The note said 62 vendored modules left and then named five packages
without counts, so the names read as the whole of the 62 and summed to
five. Each carries its own count now: 55 + 3 + 2 + 1 + 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 08:07:16 -04:00
Jinwoo Hong b76bc79d73 build(mobile): key Metro's transform cache on the shell build kind (#22244)
`babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into
`mobileShellBuildKind` at transform time
(babel-preset-expo/build/inline-env-vars.js:51), but nothing Metro hashes
into the transform cache key carries that value: the key is
`metro/src/DeltaBundler/getTransformCacheKey.js:21`, whose inputs are the
Metro version, `cacheVersion`, the transformer path and
`@expo/metro-config/build/transform-worker/metro-transform-worker.js:600`,
none of which reads the environment. A release assembled after an
opposite-kind build reuses the warm entries and bakes the wrong shell,
and the absence of the variable's name in the bundle cannot tell the two
apart. The newest published `@expo/metro-config` (58.0.4) keys it no
differently.

Folds the kind into `cacheVersion`, by the same `=== 'ota'` rule the app
applies, keeping Metro's own version as the prefix.

Proven with four `expo export --platform android` runs against an
isolated Metro cache. Before: `ota` then `native` produced byte-identical
bundles, both `return 'ota'`. After: the second run returns `'native'`
under a different bundle hash.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 07:57:43 -04:00
OrcaWinandm4air 632ae1320b fix(daemon): reap terminal descendants during shutdown (#22232)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-22 04:32:26 -07:00
Jinwoo Hong 59c0d5585e fix(mobile): the shell swaps the page's client identity so its terminal reaches init (#22201)
* fix(mobile): give the page a client identity so its terminal reaches init

The page's RPC provider answered `getClientId` with `null`, and the session
route refuses `terminal.subscribe` without a client identity. No subscribe
meant no scrollback, so `init` never reached the terminal document: the
surface stayed 0x0 and the document answered every measure with `notReady`.
`canSend` reads the same value, so live input was dead for the same reason.

The identity is the page's shell session, not the pairing credential `init`
deliberately withholds: the host only ever uses `client.id` as an opaque
in-memory key for the mobile input floor and the viewport claim.

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

* fix(mobile): the shell swaps the page's client id for the device's

The host does not read `client.id` as an opaque key: `terminal.send` refuses a
query reply whose id is not the credential the socket authenticated with, and
`terminal-send.test.ts` pins that as a spoof. So a page-chosen id was wrong on
that path however stable it was, and the session id f29a7d1958 used was wrong
twice over — it rotates per mount, and the composer's send journal refuses a
retained operation whose caller fingerprint moved.

The page now claims one fixed placeholder and the shell, which owns the socket,
swaps this device's real identity in as the frame leaves it — in both doors to
the client, the request path and the stream start. Declared on `init.accepts`,
which is optional and additive, so no protocol version moves and a page served
by a shell that does not swap claims nothing rather than sending a placeholder
the host would refuse.

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

* fix(mobile): refuse an unresolvable page client identity at the door

Stripping `client`/`mobileClient` forwarded a request the page never made: the
host reads a missing `client` as a different caller, so a shell that could not
read its own identity would degrade silently instead of failing. Both doors now
answer the page with `bridge_client_identity_unavailable`, which reaches the
caller as an ordinary rejected RPC and reaches the desktop as nothing at all.

The substitution narrows through a checked `isRecord` guard, so the module
carries no type assertion but `as const`.

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

* test(mobile): repin the session route's page closure for the client identity

`bridge/bridge-page-client-identity.ts` declares the placeholder the page's RPC
provider claims, so `client-context.web.tsx` imports it and the module joins the
session route's closure. Measured on the merged head with all five generators
run first, not summed: modules 4269 -> 4270, local modules 1021 -> 1022.

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

* fix(config): charge a JSON stream event to the shell double's window

The JSON emitter took a slot on `unacked` without adding its bytes to
`unackedBytes`, and the `ack` arm subtracts whatever it finds there. One ack
drove the ledger negative, and the binary emitter reads that floor, so every
later window check admitted frames the real BridgeHostSubscriptions would have
refused. The docblock's "on the same ledger" now holds.

A reader for the window comes with it, because the ledger is a closure variable
and the only thing a check could see before was a posted-or-dropped verdict.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:46:12 -04:00
Jinwoo Hong da1c322b00 feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native

EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in
preferences.ts. Expo's babel preset inlines a literal process.env member
expression at build time, so a release bundle carries the answer as a constant
and anything but the exact string 'ota' — unset, empty, a typo — is native.
Every default build is therefore the native app, unchanged.

mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to
mount the page comes from the build and never from storage: a native binary
installed over an OTA one, same bundle id and same data container, still refuses
a stored 'true' without reading the key. An unset key reads on only in an OTA
build; a development build keeps its opt-in, and a stored 'false' wins
everywhere so the Troubleshoot toggle can switch an OTA build back to native.

That toggle now mounts wherever the flag can be on, which is the only way back
to the native screens in an OTA build, and its label names the build kind rather
than saying "(dev)". The bundle probe row beside it stays development-only: it
fetches.

The flag census gains two rules — one module reads the switch, in the member
form Expo inlines and not the bracket form, and one named function answers the
build kind — and the build-kind fence now lists the Troubleshoot route that asks
it. Docblocks that said a store build can never mount the shell now say it
mounts only when built for OTA.

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

* ci(mobile): one workflow input picks the shell, and no input means native

Both release workflows gain a `shell` workflow_dispatch choice, options native
and ota, default native, and hand it to the step that bundles the JavaScript as
EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and
the fastlane build_and_upload step on iOS; nothing else in either file sets it.

A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'`
yields native for them — the first OTA release is a dispatch with one field
changed, and every other run is the app we ship today.

Each build step prints the value it is about to build with, read back from the
same variable rather than from a second copy of the expression, so a run's log
cannot claim a shell the build did not use.

The new contract test evaluates that expression rather than matching its text:
absent, empty and 'native' all resolve to native, 'ota' to ota, and any
expression shape it cannot evaluate is a failure rather than a pass.

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

* build: the desktop packages the real page, and the placeholder is retired

build:mobile-web now runs the app builder and app verifier, and both take their
output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each
carrying a constant of their own — one definition of where the bundle lives, so
a drift cannot leave electron-builder's beforePack looking at an empty directory
while the builder reports a tree it wrote elsewhere. build:mobile-web:app is
gone; it was the same two commands.

src/mobile-web/ and its two scripts go with it. What the app builder shared with
them is split into three modules named for what they hold rather than for the
bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the
canonical asset serialization, buildId, hashed assets, the protocol window and
the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two
failure modes are Windows paths and symlinked entries), and
mobile-web-source-line-endings.mjs (the CRLF guard, now with a required
directory rather than a default pointing at the deleted tree).

The two suites that only needed *a* valid tree on disk — the beforePack guard
and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree
instead of bundling the whole mobile graph. It goes through the same manifest
writer the page does, so a manifest shape change still reaches them.

Also retired: the placeholder's tsconfig project and its typecheck lane, its
knip entry, its electron-builder exclusion and .gitattributes pins, and the
app-bundle test that asserted the shims stayed out of a builder that no longer
exists. pr.yml's page job builds the same bundle the package job ships.

Inert for native phones: they never fetch it.

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

* style(config): one import of node:fs/promises in the entry-detection suite

The changed-code quality gate's focused plugins read the two as a duplicate
import; the readFile line was left over from the split.

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

* docs: the comments that still describe the retired placeholder bundle

The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app
and shipped by nothing. That script, that directory and that fact are all gone:
it is built by `build:mobile-web` into the packaged bundle dir, and a phone
mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota.

Two Windows cache keys explained themselves by naming src/mobile-web and "the
two bundle builders"; config/** now covers the builder, the verifier and the
manifest writer, and the spike's key no longer waits on a Phase C flip that has
happened. The keys themselves are unchanged.

Three scratch directories in the app-bundle suites and one in the verifier still
spelled the retired output root. Renamed to mobile-web, which is what the build
writes; they are temp subdirectory names and nothing reads them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:18:33 -04:00
Jinwoo Hong 9a25e318f6 fix(mobile): write live-input text through a seam the page can honour (#22189)
* fix(mobile): write live-input text through a seam the page can honour

On React Native Web a TextInput ref is the DOM node itself, so
`setNativeProps` does not exist. The two terminal live-input hooks called
it on `liveInputRef.current`, and the session route reaches one of them
from a mount effect, so the write was a TypeError that faulted the whole
page: the shell tore the view out, the terminal surface stayed 0x0, the
IME never opened and the accessory bar did nothing.

Both now write through `terminal-live-input-text-write.ts`, whose `.web.ts`
sibling sets `value` on the `<input>` or `<textarea>` RN Web renders. That
covers the case React has no commit to make, which is where an interrupted
IME composition leaves the field.

The browser oracle is new: the session render check cannot reach this
defect, because its shell double answers no RPC, so no terminal handle
exists, `liveInputEnabled` is false, the field never mounts and the write
is skipped by its own optional chain. The new check mounts both hooks
against a real field on the page bundle instead.

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

* test(config): fence every setNativeProps write with a web sibling

Fenced by absence rather than by a list of approved callers: any mobile
source module that calls `setNativeProps` must ship a `.web.ts(x)` for the
page bundler to resolve in its place, and no web sibling may make the call
itself. Run against the two hooks as they were, it names both.

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

* docs(mobile): trim the live-input seam's comments to what is not obvious

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

* fix(mobile): move the settings scroll lock into a seam, and fence the route tree

The census scanned `mobile/src` only, so it read the terminal-settings
screen's `setNativeProps({ scrollEnabled })` as absent. It scans `mobile/app`
too now, under a stricter rule: a route file may not make the call at all,
because expo-router registers a `.web.tsx` beside a route as a second route
rather than as a platform sibling, so a route cannot own a platform split.

The one offender it named moves into `src/terminal/terminal-settings-scroll-lock.ts`,
beside the styles module that already owns this screen. Measured rather than
assumed: RN Web puts an `HTMLDivElement` in the ScrollView ref with no
`setNativeProps` at all, and drives the scroller's overflow from a generated
class with nothing inline, so the sibling locks with an inline `overflowY`
and unlocks by clearing it. `overflowY` rather than `touch-action`, which
stops a touch drag but leaves the wheel scrolling.

The seam does not enter the session route's closure — terminal-settings is
not under `app/h` — so the module pin is unmoved at 4270, re-measured.

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

* test(mobile): read the absent setNativeProps with `in`, not Reflect.get

`in` walks the prototype chain, so the claim that RN Web has no such method
to inherit is the same one, and the anti-slop gate has no dynamic read to
object to.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:15:34 -04:00
Jinwoo Hong 6ae5ef2d00 fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) (#22182)
* fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up)

`nodePaths` is a fallback esbuild consults only where normal resolution
fails, so it never reached the four modules under `src/shared` that the
page imports: sitting above `mobile/`, their bare `zod` resolved upward
to the root's 4.5.4 while the 58 mobile modules beside them resolved to
mobile's 4.4.3. Both shipped -- 808,470 bytes of duplicate source, and
salvage combinators built by one instance nested inside schemas built by
the other.

Mobile's copy, because the mobile app already says so: `mobile/tsconfig
.json` maps `zod` to `./node_modules/zod`, a shared module joins that
program as an imported file, and `--traceResolution` shows tsc holding
`zod-salvage.ts` to 4.4.3 today. The bundler was the only layer that
disagreed with the app's own compile-time contract.

The build drops from 67 scripts to 66 and from 8,055,568 bytes to
7,686,714, nearly all of it before the first route: the entry's static
closure falls from 1,612,253 to 1,244,312. So the C7.8 sweep is
re-measured rather than bumped, and the entry-budget note's static-import
readings are re-measured with it.

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

* test(mobile): re-measure the pins the second Zod was inside

The session route's module closure and the asset ceiling both counted
the root's copy. Both re-measured rather than adjusted to fit.

The closure falls 4363 -> 4269. The two module lists were diffed rather
than the total inferred: 94 entries gone, every one of them `zod@4.5.4`,
none added, because mobile's 79 were already in the closure, and the
`local` count holds at 1021 -- this took no source module out of the
page, only the second copy of a package.

The asset ceiling is derived from the chunk envelope, which the
re-measured sweep moved by one, so 30 routes now derive 248 assets and
31 derive 257. The crossing it exists to name is still 31.

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

* test(mobile): align three readings with the head that measures them

All three described a bundle with two Zods in it, and the third was
already stale on main.

The escape-hatch note said 5 of the 14 routes break the entry budget on
their own. Re-measured by making one route's manifest entry a static
import and reading the entry's own static closure back, it is one:
session at 3.32 MiB, with five more between 1.85 and 2.17 of the 3 MiB.
Same readings as the note in verify-mobile-web-app-bundle.mjs, which is
the thing this case asserts against.

The mermaid pair said the bundle "emits 69 scripts". It emits 66, and
this head's own fourteen-route prefix reads 64. The pair stays at 69 and
172: what the case pins is that the envelope tells the two apart, not
either build's size. Saying so in the comment, with the warning that 69
now sits just under the envelope -- a sweep that falls further fails this
on a frozen number, which is a signal to re-measure the pair rather than
to raise the ceiling.

The assets line said 215 against 112. 215 still derives from the pinned
172; 112 never matched the envelope it claimed, which allowed 114 on main
and allows 113 here.

The route count is the one number here that has to follow the tree, so it
is spelled and pinned to the sweep's own length. The static-import
readings are measurements rather than table counts and the mermaid pair
must not move at all, so neither is spelled.

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

* test(mobile): cut the mermaid and census comments to their claims

Round 2 explained the frozen pair over four paragraphs and the census
row over six lines. Both now say what they are for and stop: the pair,
why neither number moves, and the one warning that matters.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 05:23:33 -04:00
Jinwoo Hong a932147308 test(config): the render rig's navigation wait is bounded, and the frame-budget sweep reads the frames its capture painted (#22177)
* test(config): the render rig's navigation wait is bounded and says why it gave up

`waitForRecordedNavigation` ticked every 10 ms until the case's own timeout,
so an arm whose click missed its 2 s actionability window waited for a record
nobody would write and failed as a bare timeout with the click's error
swallowed. Measured on this tree: with the click pointed at a selector that
does not exist, both engines failed with `Test timed out in 120000ms` and no
mention of the click. C8.1 round 1 saw the same shape at 240 s on CI and
dropped a render arm for it.

The bound is 10 s, sized from the rig rather than chosen: the four arms that
take this path, three runs each on both engines, answered on the loop's first
check at 0 ms in all 24 readings, and in 24 more taken while two full
`config/scripts` suites ran beside them, where the slowest whole case was
2859 ms. Past it the wait throws naming the arm, how long it waited, what the
click did and what the frame last read; the same case now fails in 14.4 s.
The happy path is unchanged -- the first check still answers it, with no
added wait.

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

* test(config): the frame-budget sweep reads the frames its own capture painted

The sweep decided which screencast frames carried the noise by counting
arrivals after the raster barrier, and frames do not reach the client in
capture order. Measured directly against Chromium through CDP at 1400x1600,
six captures at 20x CPU throttling: every frame of the black canvas the resize
left, and every frame still in flight from the previous viewport, was stamped
86 to 161 ms before this capture's paint and still arrived after the barrier,
while every frame carrying the noise was stamped inside 150 ms after it. A
black 1400x1600 frame encodes to 13483 bytes, which is the 0.006 bytes/px read
on 2026-09-22, and a full frame of a previous smaller viewport is the ~447 KB
whose posted envelope was the 596462 that 2026-09-21 expected to be null.

So the precondition is a reading rather than an ordering: the paint hands back
the page's own clock, `Page.screencastFrame` carries the browser's capture
time on that same clock, and only frames stamped at or after the paint are
admitted. A frame with no capture time is not admissible either, since it
cannot be told from a stale one. When none is admissible the error prints every
frame with how long after the paint it was captured, instead of understating
the budget in silence.

The healthy reading is unchanged -- 0.55296 max, 0.54399 min over three idle
runs and three under two concurrent config/scripts suites, against 0.55296 /
0.54399 before -- and so is the cost: 18.4 s against 18.1 s.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 04:11:08 -04:00
Jinwoo Hong 48bdbb8e24 refactor(mobile): the host-scoping rewrite moves out of the terminal's HTML (#22172)
`scopeDocumentStyleToHost` and `scopeStyleToHost` are one rewrite of a flat stylesheet, and two
page mounts read it: the terminal's and the rich Markdown editor's. The module lived under
`terminal/terminal-webview-html/`, so the editor reached across the terminal's directory for it.
It moves to `src/style-scoping/`, named for what it does rather than for its first caller, and
both mounts import it from there. No re-export shim: the old path is gone.

Its test does not follow it whole. Four of its five cases read the terminal's own sheets
(`TERMINAL_DOCUMENT_*`, `XTERM_ENGINE_CSS`) and the first asserts `document-style.ts`'s split
identity, which is not about the rewrite at all -- so that file stays in the terminal directory as
`document-style.test.ts`, beside the module it is about. What moves is the part that names no
caller: which selectors read as the document's own, and the sheet shapes both exports refuse.

The page-closure pin names the module by path and is updated in the same commit. The closure is
otherwise unmoved: 4,363 modules and 1,021 local before and after, one path swapped for another.
The five generated artifacts are byte-identical -- none of them bundles this module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 03:44:35 -04:00
Jinwoo Hong 895f2cf477 test(mobile): the web app's script fence is re-derived from a measured sweep (OTA phase C, C7.8) (#22152)
* fix(mobile): re-derive the web app script fence from the measured spread (OTA phase C, C7.8)

`4r + 16` was a guess at break-even, and its slack ran from 17 scripts at one
route to 4 at thirteen -- loosest where nothing is and tightest where the tree
actually sits. Re-measured by building every prefix of the sorted route key
list: 15 routes emit 67 scripts, and the marginal cost of a route runs 1 to 9
depending on what it shares, so no line through the route count is both an
upper bound and a budget.

The sweep is now the fence's only input. The envelope is the measurement plus
one margin at the swept tree, growing by the worst route the sweep saw for
every route past it, so a new route breaches it only by costing more than any
route measured. The margin is four, which is the most the count has been seen
to move at a fixed route count with no route added: the head that wrote the old
fence read 32, 43, 61 and 69 at 8, 10, 12 and 14 routes where this one reads
34, 44, 57 and 65.

Two-sided in the test, which is what stops the next bump: a build more than the
margin under the envelope fails there too, so the fence has to be re-measured
rather than raised. The route count stays the only term and the mermaid control
still tells one artifact from 172 chunks.

The shell-fit crossing comes in from 50 routes to 31 with it, because the
envelope grants the worst swept route where `4r + 16` granted four. Byte budgets
re-read from this build and unchanged: 8,053,438 of 9,437,184 total, 1,612,006
before the first route of the 3 MiB allowed.

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

* fix(mobile): spell the session route's grant counts off the table (OTA phase C, C7.8)

#22072 deleted `native.wakelock.set` from the session route and left both prose
counts behind: "Fourteen grants" for a list of thirteen, and "the four audio
verbs" for the three that remain. The all-or-none reason went stale with them --
it argued from a screen free to lock, which is the verb that was removed, and
the device side has owned that lock since. It now argues from the microphone a
route granted two of the three cannot close.

The census beside the route-declaration test is what stops the next one. It
reads which number words appear before "grants", "audio verbs", "media verbs"
and "or none" anywhere in the file and compares them with the list itself, so a
second spelling left in place fails rather than passing on the first correct
hit, and a comment rewrapped at a different column still matches.

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

* test(mobile): the four sibling comments spell their grant counts off the tables too (round 1)

#22072 removed `native.wakelock.set` from the session route and left the count
standing in four more files than the route table: "fourteen grants" in the
call-site census and in the hop census, "the four audio grants" in the census
and its test, and "the four audio verbs" on the bridge schemas. Each is now
counted off the table it describes rather than copied beside it.

The six-versus-eight split in the call-site census could not be re-derived --
its own "those eight" never summed to the six rows plus the audio grants, so it
was wrong before #22072 too. It is replaced by what the tables say today: six
rows pin eight of the session route's thirteen grants, and the other five have
censuses of their own. Both numbers come from `PAGE_GRANT_CALL_SITES` and the
route list, and the existing case that names six rows covering eight grants is
what holds them.

The census the route table got is now `spelled-count-census.mjs`, driven from
three files instead of one. A phrase restated in a file collapses to one claim,
so a header and a test name may spell the same count; a phrase with no number
before it reads as the empty list, which no table count matches.

The bridge test is the one site with no count left to pin: it names
`BRIDGE_NATIVE_VERB_NAMES` instead, and a new assertion holds its own
`AUDIO_VERBS` equal to that table's audio rows, so the cases below cannot pin
one set while reading as coverage of another.

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

* test(mobile): count the session sentence off the session intersection (round 2)

`'of the session'` was counted off every grant in `PAGE_GRANT_CALL_SITES`, which
is not what the sentence it pins is about. A row pinning a grant no route
declares -- the shell can serve a verb before a screen asks for it -- would have
made the census demand the comment overstate what the session route has.

Proved before fixing: adding `native.share.send` to the navigate row moved
`pinnedHere` from eight to nine while the session intersection stayed at eight,
and the census failed asking for "nine of the session route's thirteen grants"
against a sentence that was right to say eight. With the intersection it passes
under the same probe, and the failure moves to `'grants this file pins'`, which
is about this file's rows and does correctly demand nine.

The other three rows are left as they were, for the same reason read the other
way: `'grants this file pins'` and `'did not'` are about the rows here, so they
keep the full list, and `'have censuses of their own'` and
`'are not repeated here'` already count the session grants no row pins.

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

* test(mobile): pin the two counts in this file's it titles too (round 3)

The census pinned the JSDoc counts and stopped there, so "names six rows
covering eight grants" and "reaches every one of the eight through the session
route" were free to go stale. Shown rather than argued: with a ninth grant on a
row and the header corrected to nine the way the old census forced, the suite
went green with both titles still saying eight.

`'grants this file pins'` becomes `'grants'`, which reads the header and that
title as one claim -- they are the same number, and a row per site would have
let them disagree while both passed. The session title takes the intersection,
for the reason round 2 gave.

Every other spelled number in the file is not a count of a table: "the two
tables" is how many sources the census reads, and "the one it reads", "a new one
cannot be missed", "any one grant went missing" and "every one of" are
quantifiers with no table behind them.

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

* test(mobile): count the session-route title off the rows it asserts over (round 4)

`'through the session route'` took the intersection, but the title it pins heads
an assertion that compares `grantsNeeded` with every row's grants. The title
names the session route and is not a claim about it: it says the rows here are
all reached through that route, so it moves when the rows move.

In the divergence the JSDoc already described, the two parted. With a ninth
grant on a row the assertion compares nine while the census held the title at
eight and passed, leaving a title reading below the assertion under it.

Exactly one row takes the intersection now, the `.mjs` sentence for how many of
the session route's grants these rows cover, and the JSDoc says so rather than
describing a rule with two members.

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

* test(mobile): two wording fixes, and the byte readings re-measured on the merge (round 5)

pullfrog on the census JSDoc, both correct. "make the census demand overstates"
was a finite verb where the bare infinitive belongs. "Every other row counts the
rows here" was contradicted by its own table: the rows reading the route's list
and the rows counting grants no row pins take neither count, so the sentence is
scoped to the rows that choose between the two and says what the rest read.

The two byte readings in the fence doc are re-measured on the merged tree, since
they name a head: 8,055,568 of 9,437,184 total and 1,612,253 before the first
route. C8.1 added no route, so the sweep and the envelope are untouched and the
tree still builds 15 routes into 67 scripts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 02:43:35 -04:00
Jinwoo Hong e47ef8cc28 feat(mobile): the shell tells a page which optional capabilities it has (OTA phase D, C8.1) (#22141)
* fix(mobile): publish page-route pairs the strict host schema accepts (OTA phase D, C8.1)

`routeViewOf` handed the manifest's own route entries to the host as
`pageRouteGrants`. The phone reads a manifest route loosely, so an entry
arrives carrying whatever field the desktop that wrote it knew about, and
`BridgePageRouteGrantsSchema` is `.strict()`: one unread key refuses the
pairs, `createBridgeHost` refuses the route with them, and the page gets no
`init` at all rather than losing one field.

Fixed before any route carries an optional grant (ruling 37.4), so the
manifest field the next commits add costs an installed shell nothing.

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

* chore: drop the closure and bundle probe scripts from the tree

Scratch measurements for C8.1 (which route closures reach the HTML preview,
and what the preview render rig costs to bundle with a client provider). They
belong outside the repository and were swept in by the previous commit's
`git add -A`.

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

* feat(mobile): a manifest route may declare optional grants (OTA phase D, C8.1)

Design B of design-ota-c8-1.md, ruling 37. `MobileWebBundleRouteSchema` grows
`optionalGrants` under the required lane's own grammar, with the 16-name
ceiling applied over the union of the two lists rather than to each. Serving a
route still reads `grants` alone, so a capability a screen cannot work without
stays required and takes the route native; a session's granted list is
`[...grants, ...optionalGrants]` narrowed to what this shell implements, from
one helper that both `grantsForRoute` and the `pageRouteGrants` publish read.

The ruling's compatibility rationale is corrected in place. `z.looseObject`
passes unknown members through rather than dropping them (measured, zod
4.4.3), so a shell older than the field still receives the key; what it lacks
is a policy that reads one. What makes the lane safe against such a shell is
therefore the previous commit's publish fix, not the reader.

BRIDGE_PROTOCOL_VERSION stays 1. No new notify, verb or frame field.

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

* feat(mobile): name the shell's cancelled-navigation behaviour as a grant (OTA phase D, C8.1)

`externalNavigation` joins `MOBILE_WEB_SHELL_GRANTS` beside `screencastBinary`
and `haptics`, declared in `cancelled-navigation-target.ts` because that is
the module holding the rule which acts on it. A third token that is neither a
verb nor a notify: the page posts nothing to make a cancelled top-frame
navigation happen, so this list is the only thing that can tell a page whether
a tap inside the sealed HTML-preview frame escapes at all.

A constant and not a platform read (ruling 37.1): both engines dispatch the
event, `ios/MobileWebShellView.swift:481` and Android's
`MobileWebShellView.kt:382`, so an app build carries the behaviour on both or
on neither.

The policy census grows the half that was only pinned by the verb table: the
implemented set is that table plus exactly three non-verb tokens, each read
off the module that declares it.

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

* feat(mobile): the bundle builder carries a route's optional grants (OTA phase D, C8.1)

`resolveMobileWebPageRoutes` maps each declaration member by member, so a
field the declaration grows reaches a phone only once the map names it: until
now `optionalGrants` would have been dropped in silence and every route would
have declared nothing optional. Omitted when the route declares none, because
absent and empty are the same answer to a shell.

The declaration suite grows the rule rather than a row: the map carries the
lane through and writes no key without one, and the lane is held to the
manifest's own grammar and to the ceiling over the union of the two lists.

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

* feat(mobile): the HTML preview hides its links on a shell that cannot open one (OTA phase D, C8.1)

The session route declares `externalNavigation` on the optional lane, and the
preview asks for it before it renders an artifact's links as links. Ruling
37.2's three readings are what "hide" means here, and removing `href` is what
delivers all three at once: `a:any-link` stops matching, so the UA stylesheet
stops underlining, the element leaves the tab order, and there is no dead
anchor a tap does nothing on. The text the author wrote stays where it was,
the artifact paints, and the Preview/Source toggle is untouched.

Done with the browser's own parser rather than over the source text: an `href`
inside a comment or a `<template>` is text to a browser, and a pass that
rewrote either would be editing the artifact instead of its links. The frame
also loses `allow-top-navigation-by-user-activation` on that path, so a link
the pass somehow missed is refused by the browsing context as well.

One route, measured rather than assumed: the design said two, and the file
preview route's closure does not reach the HTML preview at all - it renders
`MobileFilePreviewScreen`. The new closure census derives that list from the
hook's callers.

The render rig grows the case on both engines and the readings it needs, and
`mobile-web-app-preview-frame-readings.mjs` is split out of it at the
readings/arms boundary, because the two were over the 600-line cap together.
Two engine findings are recorded in the rig: an `<a>` with no `href` still
answers `tabIndex` 0 on both, so focusability is asked by focusing; and WebKit
computes `cursor: auto` for a real link, so that reading is pinned where it
discriminates and its blindness pinned where it does not.

The hop-coverage census now reads the effective set, because that is what the
running rule compares. Inert today: the session route is the only declarer and
an opener into every other route.

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

* test(mobile): pin the preview's hidden link path where the unit suite can reach it (OTA phase D, C8.1)

The mobile suite runs in a `node` environment whose resolver has no `.web`
precedence, so `MobileHtmlPreview.web.tsx`'s import of the grant hook lands on
the native sibling, which answers yes unconditionally. That is why the
existing component suite still measured the granted frame without knowing a
grant exists, and it means the hidden path had no coverage in the sharded
`test` job, where the render rig is skipped for want of the bundler's
dependencies.

So the wiring gets its own file with the module replaced: that the component
asks, and that both the frame's sandbox and the document it is handed follow
the one answer. happy-dom rather than the suite default, because the inerting
pass parses with the browser's own `DOMParser`.

`String(node.type)` rather than a literal comparison: `node.type` is
`ElementType`, which overlaps a real intrinsic tag and not the host strings
these mocks render, so `=== 'Pressable'` is a no-overlap error under
`tsconfig.test.json` and the tests-typecheck ratchet reds on it.

Also replaces a `Reflect.get` the anti-slop gate refuses with an `in` check.

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

* test(mobile): repin the session page closure at 4,362 for C8.1's three modules

Measured on both sides with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base
`841d06a969` with all five postinstall generators run first, and the two
`local` lists diffed rather than the total inferred: 4,359 -> 4,362 modules,
1,017 -> 1,020 local.

All three are local source modules and none is vendored: the page's read of
`init.grants.native`, the pass that turns an artifact's links back into text
without the grant, and the module declaring the token beside the rule that
acts on it - reached both by that hook and by `page-route-policy.ts`. The
`bridge-caps.ts` it imports was already in this closure, and the hook's native
sibling is replaced rather than joined.

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

* test(mobile): allowlist the preview's grant sibling among the .web.* overrides

`mobile-web-app-web-overrides.test.mjs` pins the allowlist against the `.web.*`
files on disk, so a new web sibling reds it until the file says why the page
needs one. Red before: `expected [ …(36) ] to deeply equal [ …(37) ]`, naming
`src/components/use-html-preview-link-grant.web.ts`.

The preview's own entry is corrected with it: its reason said
`allow-top-navigation-by-user-activation` is granted, and that token is now
conditional on the shell answering that it can open such a navigation.

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

* test(mobile): the hidden-link render case waits on the frame's own reading (round 1)

CI's chromium arm timed out at the full 240 s on this case alone while the
WebKit sibling passed in 1.5 s and it passed 26/26 locally. The cause is the
third arm: it tapped the granted link and waited through
`expectNavigation: 'main-frame'`, and `waitForRecordedNavigation` has no bound
but the case's own timeout. Under CI load the click missed its 2 s
actionability window, no navigation was ever recorded, and the arm sat in that
wait until vitest gave up - `recorded []`, with the frame attached only at
38.9 s. Three arms sharing one budget is what made this the case to find it.

The arm is dropped rather than its wait lengthened or retried. Every verdict
left is a reading the frame itself publishes: the anchors its document holds,
the style the engine computed for one, whether focus lands on it, and now
whether the tap this arm made landed at all - `actError` is asserted null, so
a click that never reached its target is no longer the same three zeros as a
tap that did nothing.

Nothing is lost. The tap's outcome on a granted shell is the next case, on
these same counters from this same rig and with a budget of its own, which is
the presence precondition this file already uses elsewhere for the same
reason. The WebKit sibling's discriminating reads are untouched.

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

* fix(mobile): the inert-link pass changes nothing an engine renders but the links (round 2)

Round 2's ruling: the hidden-link path may change nothing about the artifact's
rendering except that links are not links. A parse and a reserialise is not
free of that by default, and all four findings reproduced on Chromium 147 and
WebKit 26.4.

A same-document fragment link is kept. It starts no navigation at all, so it
goes on working inside the sealed frame whatever the shell can do, and taking
it away would be degradation over a capability it never needed - an artifact's
own table of contents is the case. Its `target` still goes, because a fragment
aimed at another frame is a navigation rather than a scroll, and `href=""` is
not a fragment: it resolves to the frame's own URL.

Links inside `template.content` are reached, recursively. `<template
shadowrootmode>` is a declarative shadow root the frame's parser attaches and
renders, and `querySelectorAll` does not walk into template content, so those
links arrived live inside a sandbox that refuses their navigation - the dead
anchor ruling 37.2 forbids. Measured: `parseFromString` attaches no such root
on either engine or in happy-dom, so the pass can reach them.

The leading newline of a `pre`, `listing` or `textarea` is written back. A
parser drops one after the start tag and the serialiser is specified to put it
back; measured, neither engine's does, so a round trip lost a blank line from
every such block.

The doctype is carried whole, and the reason is corrected from the one the
finding gave. It cannot move this frame between layout modes: a `srcdoc`
document takes its mode from its embedder, and measured, a quirks doctype, the
bare name and no doctype at all all read `CSS1Compat` inside the frame. What
rewriting it does is change the document the author wrote for no reason, with
`document.doctype` observable beside a Source tab showing the original. The
render case pins `compatMode` as the blind reading it is and reads the frame's
own doctype identifiers as the one that discriminates.

Option B was not available: the frame has no `allow-scripts` and inherits
`script-src 'self'`, so nothing runs inside it and there is no injection to
carry the work.

Also drops a vacuous half of the affordance test. `renderSource()` is called
with no argument, so the markup a Source view shows is the caller's own
closure and asserting it equals the fixture passed whatever the component did.
What the component decides is whether the rewritten frame stays mounted
underneath, and that is what is read now.

`mobile-web-app-preview-arm-driver.mjs` is split out of the render rig at the
boundary the readings module already names - the rig holds what each case
claims, the driver how an arm is driven, the readings what it reports - since
the three were over the 600-line cap together. No max-lines disable or bump.

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

* fix(mobile): a fragment link is a frame navigation in this preview, so it is inerted too (round 3)

pullfrog is right, reproduced on both engines before believing it. Round 2
kept `#`-prefixed hrefs on the theory that they are same-document scrolls. In
this frame they are not: the document's URL is `about:srcdoc` while its base
URL is inherited from the embedder, so `#section` resolves against the shell's
own URL and the destination differs from the document's by more than a
fragment - which makes activating it a frame navigation, and the shipped
`frame-src 'none'` refuses it.

Measured under the shipped policy, one tap, with something to scroll:

  Chromium 147   scrollY 0, frame becomes chrome-error://chromewebdata/,
                 artifact gone, embedder reports frame-src <origin>/preview
  WebKit 26.4    scrollY 0, frame stays about:srcdoc and intact, same report

So the destruction is Chromium-only but the absence of a scroll is not: there
was no working affordance to carve out for, and the carve-out left a live link
that destroys the preview - worse than the inert text it was meant to avoid.
Both sandbox values behave the same, so this is the base URL and the policy
rather than the sandbox.

The same tap does the same thing on the granted path, where this pass does not
run, so an artifact's internal links have never worked in the preview. That is
not this change's to fix; it is recorded in
`followup-html-preview-fragment-links.md`, and the render case reads the
granted arm's violation as its presence precondition so the behaviour is
pinned rather than merely known.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 02:11:40 -04:00
Jinwoo Hong 35897da0aa fix(mobile): serialize a list the engine nested inside a paragraph (#22145)
`insertUnorderedList` puts the `<ul>` inside the `<p>` it was given rather than replacing it —
measured on WebKit 26.4 and Chromium 147 both — and `blockMarkdown` read such a paragraph inline.
A bullet list the user typed came back as the paragraph's own text with no marker, so it did not
survive a markdown round trip, on the page and in the native WebView alike.

The serializer now reads structure wherever the list sits: text before it is a paragraph, the list
is a list, text after is a paragraph. The DOM is left as the engine made it and no branch asks
which engine is running. The parse side needs no mirror — it already renders `- x` as a top-level
`<ul>`, which is the shape the fixed serializer reports, and the flat control case pins that.

The unit fixture is built through the paragraph's own `innerHTML`: the HTML parser closes a `<p>`
before a `<ul>`, so a markup string on the editor gives two siblings and would measure the flat
shape. Each case asserts the nesting it got before it reads anything.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 01:11:13 -04:00
Jinwoo Hong 9243073b73 test(mobile): repin the session page closure at 4,360 after #21705 reached it (#22135)
#21705 added agent-session-option-catalog-antigravity.ts to the option catalog
the session page reaches. It merged beside C2 (#22099), whose pin of 4,359 was
measured before it, so main reads one short. Measured at eb92222e7f and named
by diffing against the closure at 841d06a969.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 23:35:44 -04:00
Jinwoo Hong 841d06a969 feat(mobile): the rich Markdown editor mounts on the page (OTA phase C, C7.10 C2) (#22099)
* feat(mobile): the editor document reads its surface from its host's root

The markup gives the editable surface an id, and inside the WebView that is
unambiguous because the document is the page. On the page it is not: a stack
transition keeps the outgoing session screen mounted while the incoming one
starts, so two hosts carry `#editor` at once and a page-wide `getElementById`
hands both documents the first one. The seventh seam is the root, exactly as it
is the terminal's ninth (ruling 24): the WebView names none of them and gets the
whole page, the page names the element its mount planted the markup in.

Red first, `vitest run src/components/rich-markdown/document-host-root.test.ts`
against the page-wide read: 4 failed, 1 passed — content written into the second
host landed in the first, both documents serialized the first surface, an edit in
the second reported through the first document's host, and stopping the first
took the listeners off the surface the second was still using. The one that
passed is the control: a document with no root still reads the whole page, which
is what the WebView gets.

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

* feat(mobile): hold the editor's document rules under its host element

The editor's sheet says `:root`, `*`, `html` and `body` because inside the
WebView it owns the page. Appended to the head of a React Native Web application
all four restyle every screen the shell can show, so the page mount may inject
only what it owns — ruling 19's rule for `window.onerror`, applied to CSS.

The terminal's half of the scoper drops those rules and repaints through a seam,
because the colour `html, body` was setting belongs to the application. The
editor has no such seam and needs none: its host element *is* that editor's page,
so `scopeDocumentStyleToHost` moves the document's own rules onto the host — the
variables every other rule reads, the surface colour, the font, the box model —
and everything else hangs under it. A selector that merely starts at the document
(`body p`) throws rather than being rewritten into something it did not say.

Red first, `vitest run src/components/rich-markdown/page-stylesheet.test.ts`:
6 failed, 0 passed, all on the absent export.

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

* refactor(mobile): the fifteen toolbar commands are one row both surfaces render

The row of controls is not the WebView's: a press becomes an injected
`runCommand` there and a call on the page, and neither difference belongs in the
toolbar. Extracted so the page's editor does not declare fifteen rows of its own
that would drift from the phone's.

`MobileRichMarkdownToolbar.test.tsx` adds the fence a second copy would have
needed: the row names every command in the contract, exactly once. Verified red
by dropping `codeBlock` from the row — "names every command in the contract,
once" failed on the 14-member list before the case went back. The native
component's own test and the web fallbacks file stay green unchanged, which is
what says the extraction moved nothing.

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

* test(mobile): keep the toolbar test inside the tests-typecheck ratchet

`check-tests-typecheck-ratchet.mjs` reported the new file as newly failing
`tsc -p tsconfig.test.json`: the `ScrollView` mock's spread did not match any
`createElement` overload, and comparing a node's `ElementType` against the string
`'Pressable'` is a no-overlap comparison. Host strings for the mock and
`String(node.type)` for the read, rather than a cast. Ratchet back to OK at 800
files.

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

* feat(mobile): the rich Markdown editor mounts on the page

`react-native-webview` renders nothing in a browser, so C7.6 gave the page a
plain Markdown field and recorded the toolbar and the rendered view as a
degradation. Ruling 26 makes that debt rather than done: the page mounts the
document itself. `rich-markdown-web-document-mount.ts` is the editor's half of
what `terminal-web-document-mount.ts` does for the terminal — the sheet held
under the host's class, the markup planted in the host, one factory call, and a
dispose that gives the host back. `MobileRichMarkdownEditor.web.tsx` is the
component over it, with the same fifteen-command toolbar and the same controller
the phone uses, so `MarkdownReader` cannot tell which sibling it has.

Three seams are the page's rather than the window's. Messages reach
`handleMessage` directly and never `window.ReactNativeWebView`, which on the page
is the shell's bridge. The URL for Link and Image comes from `TextInputModal`:
`window.prompt` was measured to return null in both shells, so those two commands
silently did nothing. And no inset source is supplied, so `onKeyboardInsetChange`
is never called — the screen's `keyboard-occlusion.web.ts` measures the same
viewport with the same formula, and a report here would lift its bar twice.

Red first, two runs. `rich-markdown-web-document-mount.test.ts`: 9 failed on the
absent module, and its listener case is the one that holds ruling 21 — a second
mount reports its own edits and the first mount's detached surface reports
nothing, with an event dispatched on it to say so. The four new cases in
`mobile-webview-editor-web-fallbacks.test.tsx`, run against the plain field still
in the tree: 4 failed, 6 passed — no toolbar, no URL modal, and the `TextInput`
the page is meant to have lost.

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

* fix(mobile): put the editor's surface on the 16 px floor, and grow a census that can see it

`#editor` computed to 14 px, measured in both engines. iOS zooms the page on
focus of any editable under 16 px and does not zoom back, and
`keyboard-occlusion.web.ts` answers 0 for the rest of the session at a scale
other than 1 — the exact failure the floor exists for, on the page's only
full-screen writing surface. The size now comes from the text-input seam, which
is also where the two hosts part: the phone keeps the app's body size because a
WebView has no page to zoom, the page gets the raise, and one binding moves both
if the floor ever does.

The `TextInput` census could not have caught it. `modulesDeclaringTextInput`
matches JSX tags and `style` props, and this is a `contenteditable` in a markup
string sized by a rule in a stylesheet. `mobile-web-app-editable-host-font-size.mjs`
starts from the markup instead: it finds every editable host a closure declares,
follows its id to the rule beside it, and reads the size the same way — a literal
at or above the floor, or the seam's own export imported from the seam's module.
An editable with no id, or one no sibling sheet styles, is reported unresolved
rather than passed.

Red first. The rule's own file reported
`src/components/rich-markdown/document-style.ts:36` as the offender before the
fix (4 failed, 3 passed on the first run, the other three being the brace scanner
and the line-start anchor the fixtures found). The closure case in
`mobile-web-app-session-terminal-closure.test.mjs` now names the editor as the
one editable in the session route's closure and its offender list is empty:
1 passed, 4 skipped under `-t editables`.

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

* fix(mobile): the caret survives the host's URL dialog, so Link and Image insert

Measured in the render check, on both engines: the Link and Image commands opened
the modal, took the URL, and inserted nothing. The dialog is what takes the
caret — the modal focuses its own field — and `execCommand` on a document that
does not hold the selection does nothing at all. So the page had swapped one
silent failure for another: `window.prompt` returning null on the phone, and a
command with no selection on the page.

Two halves. The document remembers its caret before it waits and puts it back
after (`restoreRememberedSelection`, unconditional where `restoreSelectionOrEnd`
needs a flag, because the wait itself is the blur); if the host replaced the
content while the dialog was open, the remembered range is gone from the document
and the caret goes to the end instead. And the component answers the promise from
the drawer's `onAfterClose` rather than from the submit, because WebKit would not
take the focus back while the field still held it — with the answer released on
submit, chromium inserted and WebKit did not.

`TextInputModal` forwards `onAfterClose` for that, which is the one thing it did
not already pass through to `BottomDrawer`.

Red first, `editor-selection.test.ts` against the previous `editor-commands.ts`:
2 failed, 7 passed — the caret was left in the dialog's field, and a replaced
document did not fall back to the end. The render check's Link/Image case went
from failing on both engines to inserting on both, with the inserted image's
`naturalWidth` above zero under the shipped policy.

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

* test(mobile): the page's rich Markdown editor, in both engines under the shipped header

`config/scripts/mobile-web-app-rich-markdown-render.test.mjs`: the real component,
mounted by the real React, driven through its toolbar in chromium and webkit under
the policy read out of the shell's own Kotlin source. Sixteen cases, eight per
engine.

What it measures rather than asserts: all fifteen commands change the document,
each with the precondition that what it produces was not there first; the surface
computes to the 16 px floor and the document's own `--editor-surface` variable is
set on the host and nowhere on the root element; `ready` and `change` cross the
seam while `window.ReactNativeWebView` — defined by the rig so its absence is a
reading — is never touched; Link and Image are answered by the modal, and the
inserted image paints with a non-zero `naturalWidth`; one change per checkbox tap
and one per inline code; a link tap reaches the host instead of navigating; a
remount leaves the listener snapshot and the scheduler exactly where one whole
cycle left them (rulings 20 and 21); and two editors on one page hold their own
content and report their own edits.

Four harness facts the first runs found, each now in a comment: the entry needs
four of `MOBILE_WEB_APP_SHIMS` (`isFabric` threw `global is not defined` and every
case failed at `data-ready`); `.web.jsx` in `resolveExtensions` or
`react-native-svg` resolves its Fabric components; a `SafeAreaProvider`, which the
route's navigator supplies and a bare mount does not; and the document's markup,
not its text, as the oracle for a content reset — `### body text here` and `body
text here` read the same, so a text wait passed on the document it was replacing.

One finding, reported not fixed: WebKit's `insertUnorderedList` nests the `<ul>`
inside the `<p>` it was given and the serializer walks back out with the same
text, so a bullet list does not survive a round trip there. The phone's WebView is
the same engine, so this is not something the page introduces; the case names the
command's own element and the reset is numbered per command to work around it.

Run 5 of 5: 16 passed, 0 failed, 0 errors, exit 0, 6.58s.

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

* test(mobile): repin the session route's closure for the editor on the page

Both sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base
`9267423f22`, all five postinstall generators run first, the before side a scratch
worktree detached at that sha:

  modules        4333 -> 4360   (+27)
  local modules   991 -> 1018   (+27)

All 27 are local and none is vendored, which is the point: the editor is the app's
own code, not a library. The document's 24 modules under `src/components/rich-markdown/`
were reachable from nothing on the page while it rendered a plain field, and the
other three are the mount, the shared toolbar, and the controller with its
keyboard-inset module. Nothing leaves, because the web sibling replaces its own
native file and that file was never in this closure. Named by diffing the two
`local` lists, not inferred from the total.

`document-style-scoping.ts` is on both sides: the terminal's mount already brings
it, so the editor's second export costs no module.

The generation, measured the same way on both sides: 8,028,418 -> 8,056,166 bytes
(+27,748) across 109 assets against the 9 MiB ceiling, 85.1% -> 85.4%. The script
count does not move (67 against the 76 the chunk fence allows for 15 routes) and
neither does the entry's static closure (1,612,052 bytes against 3 MiB) — this is
code the route already reached for, not a new chunk boundary.

The grant census needs nothing: `openExternalLink` is the editor's only seam with a
grant behind it, and the session route already declares `externalLink` for six
other openers. `mobile-web-app-page-grant-call-sites.test.mjs` passes unchanged.

Closure, webview-consumer and grant censuses together: 28 passed, 0 failed, exit 0.

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

* fix(mobile): drop an oxlint directive the changed-code gate reads as unused

`check-changed-code-quality.mjs` failed with one finding: the mount effect's
`react-hooks/exhaustive-deps` disable reports no problem under that config, so the
directive itself is the finding. The reason it carried is worth keeping and now
reads as a plain comment — the effect mounts once, with `promptForUrl` taken from
the closure, because re-running it would throw away a live document and the caret
in it.

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

* fix(config): the editable-host census counts every editable tag, not every id

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:50`, and right on the
code: the pattern started from `id="…"`, so it matched only hosts that carry one.
The no-id guard fired for a file with *no* named host at all, which means a file
holding a named host beside an anonymous one reported the named one as clean and
said nothing about the other. An editable is its tag; the id is read out of the
tag afterwards.

Also `:145`, also right: the sibling search was `startsWith(directory + '/')`,
which reaches the subtree, and the walk stops at the first file whose sheet opens
the host's selector. The closure's order is the bundler's rather than
alphabetical, so a sheet one directory down could answer for the sibling the host
actually gets. Now the immediate directory only.

Red first, both cases in the census's own file. The mixed fixture reported one
host where two were planted (1 failed, 7 passed); the nested fixture, with the
nested sheet first in the closure and a compliant 18 px rule in it, hid a 14 px
sibling and reported no offender (1 failed, 8 passed). 9 passed after.

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

* fix(config): an editable with no declared size is unresolved, not a pass

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:110`, and right for the
CSS case: `readFontSize` answered `onSeam: true` for a rule that declares no
`font-size`, so the offender check accepted the host without being able to say
what size it gets. The inherited value comes from a rule this walk does not read —
the host element's own, or the page's root — and it can be 14 px.

So "no declaration" becomes "cannot say" and lands in
`unresolvedEditableHostStyles`, which the session closure census holds at empty.
Not an offender: an offender is a size this walk read and found under the floor.

The `TextInput` half of the seam still lets an absent `fontSize` through as
inheritance. That is main's policy and it is about a prop rather than a cascade, so
it is not touched here; the divergence is stated in the reader's own comment.

Red first: the inheritance fixture reported no unresolved host where the size is
unknowable (1 failed, 8 passed), 9 passed after. The real tree is unaffected —
the editor declares its size on the seam — and the closure census still reads an
empty unresolved list.

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

* fix(config): the editable-host census reads the font-size the cascade uses

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:119`, and right: the walk
read the first `font-size` in a rule, and CSS takes the last of equal importance.
`font-size: 16px; font-size: 14px;` was therefore reported compliant for a surface
the browser renders at 14 px. `!important` outranks every declaration that is not,
whatever the order.

The flag is also stripped from the value, which the finding did not name but the
fixture caught: without that, a compliant size carrying `!important` was reported
as an offender, because it matched neither the literal nor the substitution shape.

The declarations are split on the separator rather than matched with a value
pattern. A pattern excluding `}` cut `${TEXT_INPUT_FONT_SIZE}px` at the brace of
its own interpolation and reported the real editor as an offender — caught on the
first run of the fix, and the reason the split is the shape here.

Red first: 2 failed, 9 passed — the repeated-declaration fixture reported no
offender, and the important-declaration pair reported the wrong one of the two.
11 passed after.

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

* test(config): the render check reads the floor from the seam instead of retyping it

pullfrog, `mobile-web-app-rich-markdown-render.test.mjs:37`, and right: the comment
said the floor was read from the seam and the constant was the literal `16`, which
is the shape the seam exists to prevent. It now comes from
`textInputFontSizeFloor(mobileDir)`, the same reader the closure census uses, which
throws rather than defaulting when the seam is gone.

The assertion becomes "at or above the floor" rather than equal to it. The seam is
`Math.max(bodySize, floor)`, so a theme raising the body size past the floor raises
what the page computes; equality against the floor would have been the same stale
literal one module further away.

Two controls, both run. Raising `TEXT_INPUT_FONT_SIZE_FLOOR` to 18 in the seam
keeps the case green on both engines, because the stylesheet reads the same module
and the page computed 18 — the two moving together is the point. Replacing the
stylesheet's `${TEXT_INPUT_FONT_SIZE}px` with a literal `14px` reds it on both
engines, `expected 14 to be greater than or equal to 16`, which is what says the
assertion carries weight. Both files were restored.

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

* fix(mobile): the editable-host census reads every rule that sizes the host (round 3)

`ruleFor` returned the first exact `#id` rule and the walk stopped there, so a
later exact rule of equal specificity, or a higher-specificity subject rule that
still targets the host, could lower the rendered size unseen.

Every exact rule in the sheet is now collected in source order and read as one
cascade, and any other rule whose subject compound targets the host and declares
`font-size` makes the host unresolved rather than compliant. No specificity
arithmetic, and the sibling walk is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 22:54:32 -04:00