Commit Graph
11430 Commits
Author SHA1 Message Date
Jinwoo-H 422940ef93 refactor(mobile): extract the terminal fit-scale slice into a module
The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.

Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:39:18 -04:00
Jinwoo-H 2bce0883be refactor(mobile): extract the terminal write-queue slice into a module
The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`,
which belong to other concerns but sit here because emitted-document order
pins them here; four names stop being externals as a result.

The observer disposal keeps its guard-as-expression form behind a line-scoped
disable: the rewrite the rule asks for is outside every counted class.

Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10,
unbound catches 1, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:37:42 -04:00
Jinwoo-H 5d107e9c2f refactor(mobile): extract the smooth-scroll and cell-geometry slice
Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.

Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:35:17 -04:00
Jinwoo-H eea723c5f5 refactor(mobile): extract the selection-state-and-eviction slice into a module
The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.

Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:33:21 -04:00
Jinwoo-H 6c9ae0215d refactor(mobile): extract the terminal term-observers slice into modules
This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.

A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.

Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:31:46 -04:00
Jinwoo-H cf8c880793 refactor(mobile): give the document close its own slice file
The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.

Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:28:45 -04:00
Jinwoo-H 8cb7381bc3 refactor(mobile): extract the terminal message-bridge slice into a module
The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.

The second catch keeps its binding: it names the error and reports it.

Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:27:52 -04:00
Jinwoo-H b113a85ad8 refactor(mobile): extract the mouse-mode DECSET scan slice into a module
The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.

Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:25:41 -04:00
Jinwoo-H 4843399c8a refactor(mobile): extract the terminal url-tap group into three modules
The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.

Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.

Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.

Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:23:59 -04:00
Jinwoo-H e38f116af0 refactor(mobile): extract the terminal mouse-click-drag group into a module
The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.

Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:20:09 -04:00
Jinwoo-H f7667147ab refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.

Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:18:10 -04:00
Jinwoo-H c9880d9f67 refactor(mobile): extract the terminal path-tap group into a module
The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.

Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.

One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.

Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:15:35 -04:00
Jinwoo-H b668622d40 refactor(mobile): extract the terminal theme group into a module
The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.

Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.

Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:11:47 -04:00
Jinwoo-H 22a8a2e7e9 feat(mobile): substitute build-time constants into the emitted document
The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.

The fixture script's TypeScript loader moves beside it rather than being
written twice.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:11:46 -04:00
Jinwoo-H 4aa81071c2 refactor(mobile): extract the terminal surface-swap group into a module
The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.

Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:05:26 -04:00
Jinwoo-H 0dca316438 refactor(mobile): make indirect-pointer scroll a module, and count a fifth class
The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.

Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.

The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.

The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.

Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:01:10 -04:00
Jinwoo-H e8072835fc refactor(mobile): make WebGL loss recovery a module
The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.

The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.

Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 08:58:03 -04:00
Jinwoo-H 4bfc1da79b refactor(mobile): make the keyboard-avoidance metrics a module
The fourth group, and the first that needed a non-null assertion.

`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.

Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.

Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.

The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 08:56:37 -04:00
Jinwoo-H 931a7437fc refactor(mobile): make reflow a module, and give the generator its own tests
The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.

The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.

Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 06:25:23 -04:00
Jinwoo-H b97a206d27 refactor(mobile): make the query-reply gate a module the page can import
The second of the twelve groups, and the one that corrects the scope table's
membership rule.

`terminalDataRepliesEnabled` is written from four places, so the whole-script
census counted it among the 57 variables that cannot stay free across modules.
All four writes are in this group. Once the script is modules, a variable
written only inside the module that declares it is that module's own state, not
the document's, and it stays a `let` there. So the scope object holds what
crosses a module boundary, and the 57 is an upper bound rather than the answer;
the qualifier count the flip commit pins will be lower than the 641 measured
over the single scope, and by how much is a function of where the boundaries
fall.

Two references do cross here and are qualified: the write-queue generation this
group compares against, and the observer-disposal list it pushes onto.

Counts pinned: two qualified references, one `var` to `let`, two one-statement
`if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 06:23:21 -04:00
Jinwoo-H 0e9f38b8a5 refactor(mobile): make the mouse-report cell a module the page can import
The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.

The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.

Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.

`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.

The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.

The document is untouched, so the byte pin from 3006d8dfdf is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 06:21:34 -04:00
Jinwoo-H 46685ddc7e test(mobile): count each normalisation the move makes, separately
Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.

The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.

Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.

Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.

Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 06:16:10 -04:00
Jinwoo-H b88004d278 test(mobile): compare two terminal documents as programs, not as bytes
The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.

It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.

So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.

Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.

Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 06:03:02 -04:00
Jinwoo-H 3006d8dfdf test(mobile): pin the terminal WebView document byte for byte
The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.

So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.

The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.

Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.

Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 05:51:03 -04:00
Jinwoo Hong 96c1dd8b70 feat(mobile): budget the browser pane's frames and put its inputs on the web seam (OTA phase C, C6.3+C6.4) (#21760)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2)

The pane's frame path is written against React Native's native-prop writer, which does
not exist on React Native Web: a ref there is the DOM node, so both writes throw and the
pane never shows a frame. Three `.web.ts` siblings, each for a measured gap.

- The image and layer writes move out of `mobile-browser-frame-state.ts` into
  `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image`
  on the element RN Web sizes and flips the double buffer with one opacity write per
  layer. The pane still never re-renders while it streams.
- A `background-image` write fires no load event, so the offscreen layer would never
  become visible. The sibling arms the flip from an image decode instead, and the flip
  itself is shared with the native `onLoad` path rather than written twice.
- The data URI keeps the base64 the bridge already carried instead of encoding the bytes
  back into the same string. Measured in this tree against the `buffer` shim the page
  bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against
  under a microsecond for the carried string.

Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and
renders its existing stream-error state otherwise, so a page never waits on frames a shell
without the encoder cannot send. The grant name is a placeholder until C6.1 reports it.

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

* feat(mobile): budget the mobile view's frame area against the bridge cap (OTA phase C, C6.3)

A screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES.
Measured here: the phone's mobile view at the native device scale factor asks for a
390x712 viewport at 2x, which is 1,110,720 device pixels, and at the worst case JPEG has
at quality 72 that is a 807,559-byte message against a 655,360-byte cap — 123% of it.

The `.web.ts` sibling holds the mobile view inside that. The budget is computed rather
than written down: the cap, less an envelope this measures from the frame's own shape at
its widest (435 bytes), is what the base64 may occupy; three quarters of that is the JPEG;
divided by one named worst-case constant of 0.545 bytes per pixel it is an area of 901,271
pixels. The phone lands on a device scale factor of 1.80 and a 654,205-byte message, 99.8%
of the cap. A cap that moved and a budget that did not would be a pane going dark on a
page it could have streamed.

Web view mode is untouched, and byte-identical to the native request: there the frame is a
desktop viewport letterboxed into maxWidth/maxHeight, which the page cannot predict, so C6
ruling 1's drop-the-over-cap-frame rule is its only protection. Native is unchanged.

The constants and the assembly move to a third module because a `.web.ts` cannot import a
value from the file it shadows — the bundler resolves the specifier back to the sibling
itself — and two copies of them would drift.

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

* feat(mobile): put the browser pane's text inputs on the web font-size seam (OTA phase C, C6.4)

The pane has two text inputs, the address bar at 12px and the key row's "Type on page…"
at 14px, and neither went through TEXT_INPUT_FONT_SIZE. In a browser an input under 16px
makes iOS zoom the page on focus and never zoom back, and keyboard-occlusion.web.ts reads
a visual viewport scale other than 1 as "no keyboard" — so one focus would leave the
pane's keyboard lift at 0 for the rest of the typing session. C4.2's failure exactly, on a
screen its census does not walk: that census walks the source-control hub and the review
route, and the pane is in neither until C7 lists a route that mounts it.

The key row's input goes straight onto the seam, whose native value is the theme's body
size, so it renders at the 14px it already did. The address bar is a `.web.ts` split
instead, so native keeps the 12px meta size it has always shown; the input and the label
painted over it move together, or the address would resize on every focus.

The address bar also gets inputMode="url" on the web only. keyboardType is a native enum a
browser does not read, so the page's address bar was falling back to a plain keyboard;
inputMode takes precedence over keyboardType, so it stays undefined on both native
platforms.

One consequence recorded rather than fixed, and pinned in
config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs: the C4.2 census resolves
an import through .ts/.tsx only, never .web.ts, so it reads the native address style that
no browser loads and reports it as an offender. Whoever lists the pane's route either
teaches resolveLocal the extensions the builder already prefers, or moves the address bar
onto the seam natively at 14px.

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

* fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2)

C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the
placeholder this PR landed with while C6.1 was still choosing.

The placeholder was also unusable, which the test added here would have caught:
`GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed
verb and nothing else, so a route declaring `browser.screencast.binary` would have been
refused by the bundle before any shell saw it, and the pane would have taken its
stream-error branch for a reason no screen could report. The name is now checked against
`MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted
spelling as the failing case beside it.

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

* fix(mobile): keep the address field test inside the typecheck ratchet (OTA phase C, C6.4)

`findByType('TextInput')` does not typecheck: a host-component string is not an
`ElementType`, so the file fell out of `tsc -p tsconfig.test.json` and the tests-typecheck
ratchet reported it. Found by reading the ratchet's exit code rather than its piped tail,
which is how it was missed the first time. The element is looked up by its placeholder
instead, and the ratchet is green with 732 test files in the program.

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

* fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2)

Round 1 folds on #21754.

The undecodable arm freed the pending slot without checking whose frame had failed, while
the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints
the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame
3, then frame 3 decodes and the flip is refused because the slot no longer names its
layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has
gone still sends no further frame to recover with. Web only; native never calls this.

Both arms now answer for the frame they were armed with.

The displayable arm's own guard had no test: deleting it left `src/browser/` and the full
suite green, because the case that exercised it settled both decodes and asserted an end
state both orders produce. The harness now settles one decode at a time, keyed on the
source it was given, and the ordered case reds without the guard.

Also: the paint sibling's opacity test claimed "no re-render" while asserting two style
strings, so it is named for what it checks and the claim is counted where React is —
across ten streamed frames the three state setters are called once each, on the mount
frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so
two pre-existing reasons keep their literal em dash instead of a re-serialized escape.

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

* test(mobile): find the address input by its imported type (OTA phase C, C6.4)

`findByType('TextInput')` does not typecheck — a host-component string is not an
`ElementType` — so the file dropped out of `tsc -p tsconfig.test.json` and the
tests-typecheck ratchet reported it. The imported component is what the element is looked
up by now; the react-native mock stands it up as that same string at runtime, so the
lookup is unchanged and the file is back inside the ratchet's program.

Why the earlier run reported 0: the command was
`node scripts/check-tests-typecheck-ratchet.mjs 2>&1 | tail -2; echo $?`, and `$?` after a
pipeline is the exit code of `tail`, which is always 0. The banner line that printed was
the last line of the failure banner, not the success one. Every gate in this branch's
report is now read from the command itself, unpiped.

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

* fix(scripts): read a text input's size from the file the page loads (OTA phase C, C6.4)

The census followed an import through `.ts`/`.tsx` only, while the closure it walks comes
from esbuild, which prefers `.web.tsx`/`.web.ts`. So a style module with a platform
sibling was judged on the half no browser loads. That fails in the direction that matters:
a split whose web half sits under the focus-zoom floor reads as clean because its native
half is on the seam, which is the exact shape the seam exists to catch.

`resolveLocal` now tries the extensions in the builder's own order. The seam comparison
collapses a resolved path onto its module identity before matching, because the seam is
itself a split — `text-input-font-size.web.ts` is where the raise lives — and without that
every binding in the tree would stop naming the seam: deleting it reds both C4 route
closures.

The browser pane's own census flips from pinning its address field as an offender to
expecting none. The two C4 route closures still answer 0 offenders and 0 unresolved, run
with the closure tests enabled.

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

* fix(mobile): bound the frame envelope above every double it can carry (OTA phase C, C6.4)

Round 1 folds on #21760.

The envelope estimate serialized each metadata field as a 16-character double, so the
bound was 435 where a real frame event at its widest measures 516. Since the budget spends
whatever the cap leaves, that 81-byte shortfall was the whole margin: a frame on the budget
would have gone over the cap and been dropped. The bound is now the skeleton plus the
widest a double can print, for the nine metadata keys imported from the protocol module
rather than copied, so a tenth field cannot be added without being paid for.

Two corrections to the fold as written, both measured here.

The widest is 25 characters, not 24. Exponential form tops out at 24
(`-1.7976931348623157e+308`), but ToString only leaves fixed notation below 1e-6, and just
above it a double prints as sign, `0.`, five zeros and seventeen digits:
`-0.0000012345678901234567`. A sweep over four million random bit patterns found 25 and
nothing longer; a deterministic sweep over both forms is in the test.

And the base64 expansion has to count padding. Three quarters of the room claims up to two
characters base64 does not have for an image of 3k+1 bytes, which at a margin the budget
now spends exactly is a dropped frame. The two agree at today's envelope size because the
room happens to divide by four, so this is a latent fix rather than a reproduced one, and
the test pins the discrepancy directly instead of implying it.

Budget moves from 901,271 to 901,161 pixels; the phone's mobile view stays at a device
scale factor of 1.80. Pinning the bound against C6.1's real encoder is C6.5's, once both
are on main; the docstring says so, and says what the bound does not cover: the metadata
object is loose, so unknown keys and web view mode's letterboxed frame are ruling 1's to
drop rather than this budget's to predict.

Also: the pane census listed its own closure by hand, so "no unresolved styles" said the
walk read those files, not that they are the pane's set. It now scans `src/browser` for
every non-test module that renders a `TextInput` and asserts the list matches; a third
module planted there reds it. And the seam's native-consumer pin names the key row and the
address bar directly rather than transitively, matched at the `fontSize` property instead
of anywhere in the file — a file-wide search survives the change, because the import line
does.

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

* test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2)

Round 2 folds on #21754.

Every test for the web paint sibling handed it a `div > div` of its own making, so the
assumption it rests on — that the host's first element child is the one carrying the
frame — was only ever checked against a shape written to match it. React Native Web also
renders an accessibility `<img>` in there, and a release that reorders those children
would keep all of them green while the pane painted nothing.

One test now renders the real component, asks it which child it painted, and checks the
write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the
hand-built cases passing, which is the gap. A second case records what the `<img>` does:
the streaming path writes styles and never props, so it keeps the source it mounted with
for the life of the pane, and that is what a screen reader and the image context menu see.

react-native-web ships no type declarations, so the component comes through
`createRequire`, whose return is `any` at its own signature; the one prop it renders with
is declared rather than asserted, and the file stays inside the tests-typecheck ratchet.

The module docstring also claimed more than the code does. The frame path adds no render,
but a render from any of the pane's other state — address focus, a dialog, the view mode,
zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`,
so both land on the newest frame whether or not it has decoded. Native clobbers the same
way through `setNativeProps`. Said plainly, along with what restores the buffering.

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

* docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2)

pullfrog is right, and the test carried the same wrong claim. The note said the hidden
`<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after
saying a render from the pane's other state passes `renderedFrameSource` as `source` —
and React Native Web derives that image's `src` from the same prop it paints the
background from, so the first such render moves it.

Measured here rather than reasoned about: rendering the real component, writing a frame
imperatively, then re-rendering with a new source moves the `src` and leaves the
background where the imperative write put it. The two halves are now two cases, named for
what each one shows, and the note says the streaming writes never touch it while a render
does — so it holds the frame the pane last rendered with, not the one on screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 05:13:10 -04:00
Jinwoo Hong 4e9d5b577e test(mobile): pin the decoded screencast frame with the base64 it now carries (OTA phase C, C6) (#21769)
C6.1 (#21758) pinned the frame `decodeBridgeScreencastFrame` hands back with an exact
`toEqual`; C6.2 (#21754) made that decoder carry the wire's `b64` on the frame so the page's
data URI can reuse it. Each PR was green against the main it branched from, and their
squashes together red two of C6.1's cases on main. The pins stay exact and gain the field,
with the encoded string spelled out rather than wildcarded.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:50:50 -04:00
Jinwoo Hong 6f0fb3fe39 feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2) (#21754)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2)

The pane's frame path is written against React Native's native-prop writer, which does
not exist on React Native Web: a ref there is the DOM node, so both writes throw and the
pane never shows a frame. Three `.web.ts` siblings, each for a measured gap.

- The image and layer writes move out of `mobile-browser-frame-state.ts` into
  `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image`
  on the element RN Web sizes and flips the double buffer with one opacity write per
  layer. The pane still never re-renders while it streams.
- A `background-image` write fires no load event, so the offscreen layer would never
  become visible. The sibling arms the flip from an image decode instead, and the flip
  itself is shared with the native `onLoad` path rather than written twice.
- The data URI keeps the base64 the bridge already carried instead of encoding the bytes
  back into the same string. Measured in this tree against the `buffer` shim the page
  bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against
  under a microsecond for the carried string.

Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and
renders its existing stream-error state otherwise, so a page never waits on frames a shell
without the encoder cannot send. The grant name is a placeholder until C6.1 reports it.

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

* fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2)

C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the
placeholder this PR landed with while C6.1 was still choosing.

The placeholder was also unusable, which the test added here would have caught:
`GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed
verb and nothing else, so a route declaring `browser.screencast.binary` would have been
refused by the bundle before any shell saw it, and the pane would have taken its
stream-error branch for a reason no screen could report. The name is now checked against
`MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted
spelling as the failing case beside it.

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

* fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2)

Round 1 folds on #21754.

The undecodable arm freed the pending slot without checking whose frame had failed, while
the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints
the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame
3, then frame 3 decodes and the flip is refused because the slot no longer names its
layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has
gone still sends no further frame to recover with. Web only; native never calls this.

Both arms now answer for the frame they were armed with.

The displayable arm's own guard had no test: deleting it left `src/browser/` and the full
suite green, because the case that exercised it settled both decodes and asserted an end
state both orders produce. The harness now settles one decode at a time, keyed on the
source it was given, and the ordered case reds without the guard.

Also: the paint sibling's opacity test claimed "no re-render" while asserting two style
strings, so it is named for what it checks and the claim is counted where React is —
across ten streamed frames the three state setters are called once each, on the mount
frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so
two pre-existing reasons keep their literal em dash instead of a re-serialized escape.

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

* test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2)

Round 2 folds on #21754.

Every test for the web paint sibling handed it a `div > div` of its own making, so the
assumption it rests on — that the host's first element child is the one carrying the
frame — was only ever checked against a shape written to match it. React Native Web also
renders an accessibility `<img>` in there, and a release that reorders those children
would keep all of them green while the pane painted nothing.

One test now renders the real component, asks it which child it painted, and checks the
write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the
hand-built cases passing, which is the gap. A second case records what the `<img>` does:
the streaming path writes styles and never props, so it keeps the source it mounted with
for the life of the pane, and that is what a screen reader and the image context menu see.

react-native-web ships no type declarations, so the component comes through
`createRequire`, whose return is `any` at its own signature; the one prop it renders with
is declared rather than asserted, and the file stays inside the tests-typecheck ratchet.

The module docstring also claimed more than the code does. The frame path adds no render,
but a render from any of the pane's other state — address focus, a dialog, the view mode,
zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`,
so both land on the newest frame whether or not it has decoded. Native clobbers the same
way through `setNativeProps`. Said plainly, along with what restores the buffering.

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

* docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2)

pullfrog is right, and the test carried the same wrong claim. The note said the hidden
`<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after
saying a render from the pane's other state passes `renderedFrameSource` as `source` —
and React Native Web derives that image's `src` from the same prop it paints the
background from, so the first such render moves it.

Measured here rather than reasoned about: rendering the real component, writing a frame
imperatively, then re-rendering with a new source moves the `src` and leaves the
background where the imperative write put it. The two halves are now two cases, named for
what each one shows, and the note says the streaming writes never touch it while a render
does — so it holds the frame the pane last rendered with, not the one on screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:30:15 -04:00
Jinwoo Hong f5d2d6e757 feat(mobile): carry browser screencast frames over the bridge as base64 (OTA phase C, C6.1) (#21758)
* feat(mobile): carry screencast frames over the bridge as base64 (OTA phase C, C6.1)

`bridge-screencast-binary.ts` landed in C0 as the page's half of the binary
lane and named C6 as the owner of the encoder that satisfies it. This is that
encoder, plus the host honouring `wantsBinary`: a subscribe that asked for
binary gets an `onBinaryFrame` on the native stream, and each frame crosses as
the envelope's `event.binary` on the same `seq` ledger as the stream's JSON
events, because the page acks by that count.

The base64 encoder is grouped rather than per byte or per `fromCharCode`
window. Its docstring carries the measurement, including the part that
contradicts the design note this came from: on V8 the per-byte form is the
fastest of the three, not the quadratic one, and the chunked form it was meant
to beat is the slowest. The grouped one is here because its cost does not
depend on how an engine ropes `+=`, and Hermes is what the shell runs.

No new opcode, no `v` bump, no negotiation added: `wantsBinary` is already in
the contract and is the negotiation. Over-cap behaviour is unchanged in this
commit — a binary event over the frame cap still ends the stream, which is what
C6.2 changes.

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

* feat(mobile): drop an over-cap screencast frame instead of ending the stream (OTA phase C, C6.1)

Measured at the pane's own request parameters, a screencast frame exceeds the
640 KiB envelope on a phone layout whenever the page will not compress: JPEG's
worst case is 0.545 bytes per pixel at quality 72, so mobile view mode at
780x1424 is 811,289 bytes, 124% of the cap. Ending the stream there blacks out
a browser tab for the life of the pane over one frame.

So the two kinds of event part at the cap. A JSON event that will not fit still
ends the stream with `overflow`, because its reader cannot see the hole it
would leave; a screencast frame is dropped and the stream lives, because the
next frame is one throttle interval away and the pane is still showing the last
one. Both are asserted side by side so neither turns into the other.

A drop leaves no other trace: the diagnostic beside it prints once per host, so
a stream shedding a frame a second and one that shed a single frame read the
same. The host therefore counts them per stream for the diagnostic and keeps a
session total, and the shell's dev facts carry that total — the surface that
already shows build state, with the line moved into its own module so what it
says is pinned rather than inferred from a template. The 12-character build
prefix it has always shown is unchanged.

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

* feat(mobile): name the binary screencast lane as a grant (OTA phase C, C6.1)

Ruling 5's negotiation, and the check it asked for first: no reader of a grant
is a closed enum, so there is no blocker and nothing an older page has to
tolerate. `BridgeGrantsSchema.native` and the shell's manifest reader are both
open string arrays, and the shell reader's own docstring already states the
degradation — a grant name a build does not know leaves that one route native
rather than refusing the bundle.

What does constrain the name is the host contract's `GRANT_NAME_PATTERN`: a
grant is one camelCase token or a `native.<domain>.<action>` verb with at least
two dot segments. So `browser.screencast` and `native.screencast` are both
refused, and the lane is `screencastBinary`. `screencast` alone would be wrong:
the page can already subscribe to `browser.screencast` and receive its JSON
events, and only the binary frames need the encoder.

Added to the shell's implemented set, which is the same list `init.grants.native
` offers, so a route declaring it is served by a shell that has the encoder and
left native by one that does not. No route declares it here; C7's session route
does.

The contract-side case is a characterisation pin, not a red-first one: the
pattern already admitted this name, and the test records that the two tempting
spellings are the ones it refuses.

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

* test(mobile): check the dropped-frame total through the bridge hook (OTA phase C, C6.1)

The hook gained a required `onBinaryFramesDropped` two commits ago and this
test kept calling it without one, so the tests-typecheck ratchet went red on
that commit — caught here rather than in CI because an exit code was read off a
pipeline's last stage instead of the script.

Fixed by wiring the callback into the probe rather than by a cast, and with the
case that makes the wiring evidence instead of types: a dropped frame raises
the total the screen receives, and the stream stays subscribed while it does.

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

* refactor(mobile): keep the dropped-frame counter with the ledger it belongs to (OTA phase C, C6.1)

Declared between a getter and a method, which is not where this class keeps
state: the subscription map is at the top and the counter is the same kind of
thing. Move only.

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

* fix(mobile): serve the binary screencast lane only to a route granted it (OTA phase C, C6.1)

Reported as a gap after C6.1's third commit and ruled on: the host honoured
`wantsBinary` from any page, so a route that never declared `screencastBinary`
could still make the shell encode base64 on its behalf. That is the hole
per-route grants exist to close — the same class as a route granted only
`navigate` and `storage` reaching the clipboard.

The rule now reads the session's resolved list, which is what its route
declared narrowed to what this shell implements, and is the same set
`init.grants.native` is built from. So the host offers the lane in `init`
exactly when it will serve it.

Ungranted is not a refusal. The subscription proceeds and its JSON events cross
as before, which is the silence every other grant gives at the call site; a
page that reads its own grants never reaches that state. Both branches are
pinned beside each other, and `grantsForRoute` is pinned dropping a grant this
shell does not implement — granted-but-unimplemented and never-granted arrive
at the host as the same absence, so its rule reads one case.

The grant name moves into the module that holds the rule reading it, so the two
cannot drift. `bridge-host.ts` is at 298 of its 300-line cap after this.

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

* refactor(mobile): move the page's stream-frame rules out of the host (OTA phase C, C6.1)

`bridge-host.ts` reached 298 of its 300-line cap, so the next main merge that
touched it would have crossed under CI pressure on someone else's PR. Split
deliberately instead, at the boundary the growth came from.

`bridge-host.ts` is the host's lifecycle and its dispatch. Opening a stream is
the only frame kind whose handling is more than one line of delegation — four
refusals and, since C6.1, the binary-lane decision — so it moves whole, and
`cancel` and `ack` move with it so all three stream frames are decided in one
place. The host's `cancel` arm still chooses between a stream and a request
where it always did: a page's `cancel` names one or the other, and splitting
that choice would leave half an arm in each module.

Counted without blank lines or comments, as the rule counts them:
bridge-host.ts 298 -> 270, and the new module is 59.

A pure move. No test changed and none was added, which is what makes the
existing suites the proof: 45 files and 745 tests green on the same assertions
as before.

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

* feat(mobile): report a page that asked for screencast frames it was not granted (OTA phase C, C6.1)

An ungranted `wantsBinary` is not a refusal on the wire, so nothing crosses
back: the subscription proceeds and its JSON events cross as they always have.
That left a page which did ask getting JSON for the life of the document with
no side able to say why. `notify-refused` has covered the equivalent notify
case since C0; this is the same shape for the one frame kind that lacked it.

The rule now answers a verdict rather than a boolean, because `not-asked` and
`ungranted` are the same answer for different reasons and only one is worth
reporting. So the decision and the report read one rule, and a page that never
asked stays silent — pinned, along with a granted route staying silent, so the
line cannot start firing on either.

The wire is unchanged and pinned unchanged: the case beside this one still
asserts one JSON event delivered and zero error frames.

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

* fix(mobile): reset the dropped-frame total with the host that counts it (OTA phase C, C6.1)

Round 1 on #21758, three findings.

The real one: the count is per host and the screen's copy was not. A rebuilt
host starts its own total at zero, so the screen kept the retired host's number
until the new one dropped a frame and then read *lower* — a falling count looks
like frames coming back, which is worse than starting over. The hook now
announces a fresh count as it builds a host. That also reports zero on the
first build, where the screen is already at zero and React bails out of the
render; the two hook cases pin that leading zero rather than leave it to be
rediscovered.

Two docstrings that described nothing: `BUILD_ID_PREFIX_LENGTH`'s stayed behind
when the constant moved to the dev-facts module and had drifted above
`failureMessage`, and `page-route-policy.test.ts` kept the docstring of the
test it replaced above the one that replaced it. Both deleted; the first's text
lives on the new module.

Red-first for the reset, checked against its final expectations rather than its
first: with the one line reverted both hook cases fail on the missing zero, and
both pass with it.

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

* fix(mobile): keep the dropped-frame total out of a production build's render path (OTA phase C, C6.1)

CodeRabbit's Major on #21758. The total went into React state on every dropped
frame in every build, and outside a development build the line that reads it
renders null — so an over-cap page re-rendered the whole shell screen up to ten
times a second for a fact nobody can see. Measured, not argued: five drops,
five extra renders.

Fixed at the seam rather than with a ternary at the call site. The dev-facts
module owns the line, so it now owns the number behind it and the rule that the
number is only state where something renders it. The screen holds no flag and
no counter; it asks for both and passes the reporter on. The reporter is stable,
so the bridge host is never rebuilt for it.

`isDevelopmentBuild` becomes a call rather than a module constant. A build flag
never changes at runtime so this costs nothing, and as a constant the branch was
unreachable to anything that did not set the global before the module loaded —
which is why the production case could not be written at the screen at all.

Also fixed, found while writing that case: the screen test's
`usePageHostSnapshot` double returned a fresh object on every render, so the
host effect's identity changed each time and the bridge host was torn down and
rebuilt on every render of the screen, settling every pending request with it.
The real hook holds the snapshot in `useState` and is stable. One object for the
file now. This was masking the fold under test — the count reset to zero on
every render — and every other case in that file was measuring a rebuild storm.

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

* perf(mobile): price a screencast frame before encoding it (OTA phase C, C6.1)

Round 2 on #21758, two lows.

The encode is a base64 pass over the whole image and the window decides whether
the frame can be posted at all, so deciding after encoding made a page that had
stopped acking pay for every frame the shell then threw away — the reviewer's
case is ten 300 KB frames against a closed window, 3 MB encoded and nothing
sent. The size is knowable without encoding: base64 is ASCII, so JSON escapes
none of it and the frame is its header serialized plus exactly the image's
encoded length. `encodeBridgeScreencastFrame` is now built from that header
rather than beside it, so the shape measured and the shape sent cannot drift,
and the window arithmetic is one rule read before the encode and again on the
frame that was.

Exact, not conservative, so the drop diagnostic still reports the whole frame
and the committed byte pin is untouched.

Red-first with the real encoder wrapped in a counter: window full, ten frames,
ten encodes before and zero after, with the drop count still ten. An over-cap
frame likewise goes from one encode to none. A third case holds the other
direction — two carryable frames still encode twice — so the fix cannot pass by
encoding nothing.

Second low: the dev-facts block sat outside the only `beforeEach` and left
`routeGrants` and `client` mutated, inert only because it runs last. The shared
setup moves to file level where the mutable dependencies actually live, resets
both, and a case at the end of the file pins it — deleting the reset fails
there and nowhere else, since nothing else runs after a case that mutates them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:19:00 -04:00
8dee68a8d1 fix(terminal): preserve Polish and Option-composed text in kitty panes (#21082)
* fix(terminal): preserve Option-composed text in kitty panes

Adapt the composition fix from #20579 and the input-source correction from
#20164. Extend coverage to every Polish letter, live setting changes,
associated text, and Chromium-to-PTY word entry.

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>

* test: guard native Korean IME against background launch

---------

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>
2026-09-20 00:28:25 -07:00
Neil fa4ea57871 fix(terminal): keep Pi input visible in open synchronized frames (#21708)
* fix(terminal): keep Pi input visible in open synchronized frames

* test(terminal): keep synchronized input fixture lint-clean

Place the existing SAFETY lint directive directly on the private xterm state assertion so the repository quality gate recognizes the reviewed test-only cast.

* fix(terminal): preserve startup parse callback

* fix(terminal): bound frame close after safety flush

* test(terminal): type startup callback fixture
2026-09-20 00:13:33 -07:00
Jinwoo HongandClaude ee61e3bd41 fix(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2) (#21735)
* feat(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2)

react-native-web's `Keyboard` is a stub: `addListener` returns a
subscription that never fires and `isVisible()` is always false. A screen
inside the shell's page that waits for `keyboardDidShow` waits for the
life of the document, and the software keyboard covers whatever sits at
the bottom of it. Two C4 screens are text entry at the bottom.

`platform/keyboard-occlusion` is the pair. The native file carries the
source-control hook's logic unchanged, events and clamp and the comment
that travels with it. The web sibling reads `visualViewport`: the layout
viewport keeps its size and the visual one shrinks, so the occluded strip
is `innerHeight - (height + offsetTop)`. `offsetTop` is in it because a
scrolled or pinched visual viewport sits partway down the layout viewport
and the strip below it is not keyboard; dropping the term reds two cases.
It listens on `resize` and `scroll` — the browser scrolling a focused
input into view moves the offset without resizing anything — and reads
once at mount, because a composer opened over an already-raised keyboard
receives no event at all; dropping that read reds a third case.

`useKeyboardAvoidingPadding` is a second name rather than a `Platform.OS`
branch at the call site. Natively it is 0 and subscribes to nothing, so a
composer that asks for it renders exactly as often as it does today;
`KeyboardAvoidingView` has already moved it and padding would move it
twice. On the web it is the whole of the avoidance, that view being
driven by the events this file exists because the page never receives.

No `visualViewport` answers 0 rather than guessing.

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

* fix(mobile): lift the commit bar and the note composer inside the page (OTA phase C, C4.2)

The two consumers move onto the seam. The hub's hook becomes one line and
keeps its name, which is what the hub's state calls the number. The note
composer takes the padding as a style on the `KeyboardAvoidingView` it
already had: natively that is 0, so the prop is `undefined` and the phone
renders exactly what it rendered before; inside the page it is the strip
the keyboard covers, which is the only thing that moves the composer
there.

The census is over both future route closures rather than over the two
call sites: `platform/keyboard-occlusion` is the one module in either
closure allowed to name the stub. Red first at the base commit — run in a
throwaway worktree at `9309350864` rather than by setting the fix aside —
it named `use-mobile-source-control-keyboard-lift.ts` as a subscriber
outside the seam and found the seam's web file in neither closure.

`mounted-bottom-drawer.tsx` is exempt by name, and the census asserts the
exemption is really in both closures so it cannot outlive its subject. It
reads more than a height — `Keyboard.metrics()` for a sheet opened over a
raised keyboard, and each event's `duration` to animate with it — which
the seam does not model, and it sits in C1's, C2's, C3's and C5's closures
too, so moving it is a change to every page rather than to this domain.
Its listeners are inert on the web the same way, which is why the composer
inside it takes its own padding rather than inheriting one.

No render-check case: measured, none of the five registered routes reaches
the seam, the commit bar or the composer, and a headless browser cannot
shrink the visual viewport independently of the layout one anyway. C4.4
carries it.

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

* test(mobile): type the keyboard harness instead of asserting its fields (OTA phase C, C4.2)

The changed-code gate flagged the two `as` casts in the hoisted harness.
A return type on the `vi.hoisted` callback says the same thing and is
checked rather than asserted, which is the shape the host-list route test
already uses.

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

* fix(mobile): read a pinch zoom as no keyboard, and test the clamp (OTA phase C, C4.2 round 1)

Round-1 folds plus CodeRabbit's exemption point.

**A pinch zoom read as a keyboard.** A 2x zoom shrinks the visual viewport
by exactly as much as a half-screen keyboard, so the commit bar and the
composer moved on a page nobody was typing into. A `scale` other than 1
answers 0. Geometry alone cannot tell the two apart and a stored "no
keyboard" baseline would be a heuristic, so a keyboard raised while zoomed
is the accepted rare case rather than a guess. `scale` is read defensively
because older WebViews do not implement it, and taking its absence for
zoomed would answer 0 for every keyboard on them; mutating the guard to
key on absence reds both cases.

**The clamp had no test.** A bare subtraction left all nine cases green.
The case is a visual viewport taller than the layout one, which mobile
Safari reports mid-scroll and which would have pushed the commit bar down
the screen instead of up.

**One guard, where the test reaches it.** `occlusion`'s `viewport ===
undefined` arm was unreachable: the effect returns before calling it, and
the absence case exercised that one. Deleted, and the remaining case says
which guard it proves.

**The census exempts two files, not a directory.** `startsWith('src/platform/')`
would wave through a later `src/platform/*.web.ts` that subscribed to the
stub directly, which is the defect this census exists for. Named exactly,
with a planted subscriber beside the seam as the fixture; restoring the
directory filter reds it.

**And the moved comment claimed an inset it never subtracted.** Deleted.
Correcting a comment that was false where it came from is not a rewrite of
the logic the move carried: no statement moved with it.

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

* fix(mobile): keep the page at scale 1 so the zoom guard is not the keyboard path (OTA phase C, C4.2 round 2)

Round 2's finding changes what the zoom guard costs. iOS auto-zooms on
focus of any input under 16px; both consumers' inputs are 14px
(`typography.bodySize`), and the page's viewport meta set no
`maximum-scale`. So `scale !== 1` was not the rare pinch the guard was
written for, it was every focus — and the seam would have answered 0 on
the one flow it exists for.

The guard stays and the premise is fixed instead: `maximum-scale=1` in
both places the page's meta is written, the built document in
`build-mobile-web-app-bundle.mjs` and the bootstrap `index.html`. iOS
honours it for the focus auto-zoom and has ignored `user-scalable=no`
since 10, so a deliberate pinch still works; the input sizes are
untouched. C4.6 step i is what settles it on a device.

Three test changes and one correction.

The census took a `rootDir`, as `findWebSiblings` does: it planted
`src/platform/other.web.ts` in the real tree while the overrides census
walks `mobile/src` in a parallel worker and would read it as an unlisted
override. It plants under `mkdtemp` now, and writes the two seam files
there too, so the empty result for them is the name exemption working
rather than those files happening not to subscribe.

A case for the ruling itself: scale 2 with a viewport shrunk past what
the zoom explains answers 0. Dropping the guard reds it and the pinch
case together.

`useKeyboardAvoidingPadding` is rendered through the test renderer now
instead of called outside one, with a counter on `Keyboard.addListener`.
Making the native hook return `useKeyboardOcclusion()` reds it at two
calls; the old shape could not see that, because a hook read outside a
component never runs its effects.

Item 4 did not hold as written. `window.visualViewport ?? undefined` is
not a no-op: the DOM declares the property `VisualViewport | null` and an
older WebView omits it entirely, so the coalesce was normalising both
shapes into one `=== undefined` check. Removing it and testing only for
`null` throws on the absent-viewport case (reproduced: `Cannot read
properties of undefined (reading 'scale')`). The coalesce is gone and the
guard names both shapes instead.

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

* fix(mobile): raise the two page inputs to 16px on web instead of pinning the page scale (OTA phase C, C4.2 round 2)

`maximum-scale=1` is reverted from both metas. It fixed the right problem
in the wrong place: Android WebView honours it and iOS ignores it for
pinch, so the cost of stopping an iOS focus auto-zoom was deliberate
zoom on Android, taken from the users who need it most.

The font size is where it belongs. `src/platform/text-input-font-size.ts`
is the app's body size and `.web.ts` is that raised to 16, the size below
which iOS zooms on focus and does not zoom back. The commit bar and the
review note composer take their `fontSize` from it. A phone renders what
it rendered before: the native constant is `typography.bodySize`, so both
style objects are unchanged there.

`Math.max` rather than the literal, so a theme that raises the body size
past 16 keeps its own value.

The zoom guard stays and its rationale is rewritten to say what now keeps
the ordinary path off it: the inputs clear the floor, so a scale other
than 1 means a user pinched rather than an input took focus.

The pin is a unit case because the render check has no route to open yet.
Three assertions and what reds each: the web constant below 16 reds the
first, and a style going back to `typography.bodySize` reds the third,
which reads the two stylesheets as source because a node test resolves
the native sibling and would otherwise pass while shipping 14px to the
web. The overrides census covers the swap itself.

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

* fix(mobile): put every text input in the two closures on the size seam (OTA phase C, C4.2 round 2)

The 16px floor reached two inputs and the rationale claimed a page. Eight
more text inputs in the same two closures still declared 14px, so a focus
on any of them zoomed the document and the occlusion seam — which reads a
scale other than 1 as no keyboard — stopped lifting for the rest of that
session. "A scale other than 1 means a pinch" was false while they were
there.

All eight go through `TEXT_INPUT_FONT_SIZE`, named by the census before
the change:

  src/components/MobileSearchField.tsx:175
  src/components/SmartWorkspaceAdvancedFields.tsx:84
  src/components/SmartWorkspaceSourceField.tsx:137
  src/components/new-worktree-form-styles.ts:125
  src/components/pr-sidebar/MobileLinkPrForm.tsx:120
  src/components/pr-sidebar/mobile-pr-sidebar-styles.ts:299
  src/components/pr-sidebar/pr-comment-composer-styles.ts:20
  src/components/smart-workspace-source-drawer-styles.ts:60

Every one declared `typography.bodySize`, so there was no input carrying
a size of its own to preserve and the phone is byte-identical again. Each
of those style keys was checked for consumers first: all of them are read
by a `TextInput` and nothing else, so raising the web value moves no
other element.

The census is the rule rather than the list. Over both closures it
resolves each `TextInput`'s style to the module that really declares the
size — following a spread, because both seam-served inputs are reached
through `{ ...base, ...list }` and a walk that stopped at the first
module would have called their offence absent — and names anything not on
the seam as `path:line`. A style with no `fontSize` inherits and is not
an offender. Presence precondition: the seam's web file is in the
closure, so an empty list cannot mean a page with no inputs.

Run against the previous head it prints exactly those eight for both
routes; three fixtures under mkdtemp cover the cross-module line, the
spread, and the two non-offender shapes.

The web test's rationale named `maximum-scale=1`, which is gone; it names
the input floor now.

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

* test(mobile): make the input census prove its own enumeration (OTA phase C, C4.2 round 2 addendum)

The offender list only says every text input is on the seam if every text
input was read, and the walk could not tell "this key sets no size" from
"I could not follow this style" — both answered nothing, so a resolution
failure would have read as a clean input and the rule would have gone
quietly vacuous.

`resolveStyleKey` answers three ways now: not found, found with no size,
found with one. `unresolvedTextInputStyles` reports the first as
`path:line (key)`, and the census asserts it is empty for both closures
beside asserting the offender list is.

Measured rather than assumed, which is what the addendum asks for. The
two closures hold 12 `TextInput` elements and 13 style references; none
uses an inline style object and none is without a style prop. All 13
resolve, 12 to `TEXT_INPUT_FONT_SIZE` and one — `styles.disabled`,
combined with `styles.input` on the same input — to a style that really
sets no size. The reviewer picker is in that list at
`mobile-pr-sidebar-styles.ts:300`; it was already on the seam from the
previous commit, which enumerated from the closure rather than from the
review.

A fourth fixture plants both shapes side by side: a style with no size,
which is not an offender, and a style reached through a package import,
which is named.

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

* test(mobile): close three holes in the text-input census (OTA phase C, C4.2 fold 3)

All three of CodeRabbit's findings are on the completeness property the
addendum bought, and all three reproduced before the change: each shape
below answered 0 offenders and 0 unresolved, which is to say it vanished.

Inline style literals. The walk recorded only `object.key` references, so
`style={{ fontSize: 14 }}` was neither an offender nor a hole. Style
props are flattened structurally now — arrays, spreads, `?:`, `&&` and
parentheses down to the expressions that can really land — rather than
walked as a subtree, which had the second bug of descending into an
inline literal's own properties. `&&` is followed because
`[styles.input, disabled && styles.disabled]` is the shape this tree
actually uses; `null`, `undefined` and `false` branches contribute no
style and are dropped rather than called unfollowable. An inline literal
resolves in place, and any other shape — a call, a bare identifier —
lands in the unresolved list.

Source-order precedence. `{ input: safe, ...legacy }` is `legacy.input`
at runtime, and answering direct keys before spreads read `safe` and
called the override clean. Properties are walked in reverse source order
now, direct keys and spreads in one pass, first answer wins.

The seam by binding. `size.text !== SEAM_EXPORT` accepted anything
spelled `TEXT_INPUT_FONT_SIZE`, so a local `const TEXT_INPUT_FONT_SIZE =
14` two lines up passed, and so did an import of that name from any other
module — the regression the seam exists to stop, wearing its name. The
identifier is resolved in the declaring module and accepted only as an
import from `src/platform/text-input-font-size`.

That last one changes what a fixture must say: the existing seam case
spelled the name without importing it, so it plants the seam module and
imports from it now. Six new fixtures, all six red on the previous walk.

Re-measured at this head, both closures: 12 `TextInput` elements, 13
style references, 12 on the seam, 1 sizeless (`styles.disabled`, combined
with `styles.input` on one element), 0 offenders, 0 unresolved.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-20 01:29:30 -04:00
OrcaWinandm4air e225b4b7eb Fix stale Codex usage after reset (#21748)
* fix(rate-limits): refresh Codex usage after reset

* fix(rate-limits): converge weekly Codex reset usage

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-19 22:22:16 -07:00
Neil b5b727bddb feat(composer): restore compact branch picker UX (#21741)
* feat(composer): restore compact branch picker UX

* fix(composer): address picker review feedback
2026-09-19 19:05:14 -07:00
Jinwoo HongandClaude f3bda1bf3e refactor(mobile): seam moves and the shared shell route guard for the source-control domain (OTA phase C, C4.1) (#21732)
* refactor(mobile): open PR sidebar URLs through the external-link seam (OTA phase C, C4.1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Round-1 folds, four wordings and one ordering.

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

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

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

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

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

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

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

Three bots findings on #21732, all real.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-19 21:06:05 -04:00
84d827a6ab fix(daemon): pause producers when stream backlogs grow (#20947)
* fix(daemon): pause producers when stream backlogs grow

* fix(daemon): reset stream backpressure on socket replacement

* docs(daemon): point retention audit at current reproducer

* test(daemon): validate stream retention audit outcomes

* fix(daemon): bound the stream producer stall and leave a visible gap

Stream backpressure pauses a session's PTY with no deadline: the only
un-pause comes from the consumer draining, so a half-open peer that stops
reading without closing freezes the shell for the rest of the session.

Arm a 60s watchdog on the false->true stream-pause transition (not on the
re-assertions refresh() makes for neighbouring sessions). On fire, mark the
session stall-released: it becomes keep-tail droppable, its backlog is
thinned behind a dataGap, and the producer runs again. The existing dataGap
path makes the renderer restore that pane from the daemon's snapshot, so the
user sees the terminal jump to current rather than sit frozen. The mark
clears once the session's last byte leaves the daemon, restoring ordinary
pausing. Nothing here reports a process exit - loss of contact with a
consumer is not evidence about the child.

Also enable TCP keepalive on the stream socket so a genuinely dead peer
closes and onStreamDisconnected clears the pause.

* test(daemon): put each casting SAFETY: directive on one line

`oxlint-disable-next-line` covers only the line directly after it, so a
rationale wrapped onto a second comment line suppressed nothing and the
casts failed the changed-code quality gate. Drop the remaining JSON.parse
cast for an annotated binding.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:51:59 -07:00
921882619e fix: retire closed editor models from the app shell (#21178)
* fix: retire closed editor models from the app shell

* test(editor): use checked Monaco attachment calls

* Preserve bounded editor view caches when retiring closed models

* docs(editor): describe batched model retirement

* fix(editor): preserve cleanup work across registry replacement

* fix(editor): build editor model URIs with the file scheme

Monaco keys its model registry by `uri.toString()`, and both
`@monaco-editor/react` (via the `path` prop) and the closed-tab disposal
path built that key with `Uri.parse`. On Windows a raw path such as
`C:\repo\a.ts` parses as scheme `c`, which fails the scheme gate in
`modelService._schemaShouldMaintainUndoRedoElements`, so closed-file undo
history was dropped for every file at any size — not only the large files
the tradeoff note covers.

Add `toEditorModelUri`, the one filesystem-path -> model-key function,
built on `Uri.file` so the result always carries the `file:` scheme and
re-parses to itself. Route model creation, disposal lookup and the
still-open ownership comparison through it so all three agree; a
divergence there would dispose a model an open editor is still editing.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:51:55 -07:00
Jinwoo HongandClaude 65cde9bb80 fix(mobile): name the source-control Back controls and split the dock's Close from Back (OTA phase C, C4.3) (#21739)
* fix(mobile): name the custom-key drawer's Back for the accessibility tree (OTA phase C, C4.3)

`CustomKeyModal`'s Back is a bare `Pressable` with a label and no role, so
a screen reader has nothing to announce it as. It is reachable today from
the session sheets and the terminal shortcut settings, so this is a gap
now, not only inside the page.

It surfaces here because `screenTree` takes a screen's directory: C4.4
registering the review route puts the whole of `src/components` under the
Back rule. Fixing it in the PR that registers would make a route entry
carry unrelated accessibility work, so the census gains a case that holds
the arriving trees to the same two rules before the rows land. Red first
it printed exactly what the rule would:

  src/components/CustomKeyModal.tsx:191 role=none label=Back

Once `PAGE_SERVED_SCREENS` has the two rows, `CONTROLS` covers these trees
and the new case becomes a second reading of the same thing; it says so.

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

* fix(mobile): split the source-control header's Close from its Back (OTA phase C, C4.3)

One `Pressable` served both modes — `onPress={onBack}` with a conditional
label, `X` docked and `ChevronLeft` full-screen. The Back census reads a
control by what its handler does, so it sees that one as a Back and then
requires a label starting with "Back", in a mode where the control
dismisses the dock beside the terminal. The cheap way to go green is to
call a close "Back", which satisfies the rule by making the wording wrong.

So the modes become two controls. Embedded presses `onClose` and is named
"Close source control"; otherwise it presses `onBack` and is named "Back
to session". Both carry the button role. The panel stops choosing by mode
and passes both handlers; the dock keeps exactly the behaviour it had, its
dismiss still `onRequestClose` falling back to a pop.

Probed before it was written: run through the census's own predicate, the
post-split shape yields one back control rather than two — `onClose`
matches neither the handler pattern nor a declaration this file holds,
being a destructured prop — so the Close is invisible to the rule and the
presence precondition still holds on the Back.

The component test is what the census cannot do. Nothing else pins this:
no golden names this component and no parity census covers
`src/source-control`. Red first against the single control, both cases
failed on the missing role. And renaming the Close to "Back to close
source control" — the gaming this split exists to prevent — reds the
component test while leaving the census green, which is the whole argument
for having both.

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

* fix(mobile): assert a Back per arriving tree, and name the refresh control (OTA phase C, C4.3 round 1)

The arriving-trees presence case counted controls over the union of both
trees, so one tree answered for the other. Per tree now, in the shape the
block above it already uses per screen module.

Red first, with the mutation round 1 named: rename the route branch's
handler to `onDismiss` and its label to `Return to session`, and the only
Back in `src/source-control` disappears. The per-tree case names that
tree. The same mutation against the union count passes all six cases,
which is what the change is for.

The refresh control had a label and no role, so react-native-web renders
a `div` carrying `aria-label` and a screen reader announces no control.
Its two siblings in this header already carry one.

Two claims in my round-1 report were wrong and I am the reason the body
carried them. The Close pressing `onBack` reds the census's label rule as
well as the component test, not the component test alone; and no fixture
of the post-split shape exists — the shape is read from the real file.
Both were stated from reasoning rather than from a run.

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

* test(mobile): assert an arriving Back per screen, not per tree (OTA phase C, C4.3 round 2)

Round 1 moved the arriving-trees presence assertion from the union to
each tree, which was not far enough. `src/components` holds two Backs, so
the tree answers for both: renaming `MobileDiffReviewHeader`'s handler to
`onDismiss` and its label to `Return to session` leaves every case in the
file green, with `CustomKeyModal.tsx:191` standing in for the screen that
just lost its Back. Reproduced before the change — six passed with the
review header's Back gone.

Per screen module now, which is what the table above already does and for
the same reason its docstring gives: a directory with more than one
control cannot say which one a rule was written about. The two modules
are named and the trees derive from them, so the pair C4.4 adds to
`PAGE_SERVED_SCREENS` is the same pair spelled once here.

Both mutations red on the new case and name the file: the review header's
rename names `src/components/MobileDiffReviewHeader.tsx`, and round 1's
source-control rename still names its own, so this does not trade one
cover for another.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-19 20:51:47 -04:00
NeilandXiro The Dev ee354a35d7 feat(agents): add OpenCode 2 beta support (#21418)
* feat(agents): add OpenCode 2 beta support

Co-authored-by: Xiro The Dev <lethanhtrung.trungle@gmail.com>

* fix(opencode2): support current plugin lifecycle and session storage

* fix(opencode2): preserve lifecycle ordering and full session capture

* test(opencode2): cover setup event bridge

* test(opencode2): cover setup event bridge

* test(browser): satisfy anti-slop naming check

* test(opencode2): cover live form lifecycle

* fix(relay): preserve OMP config directory selection

* test(opencode2): avoid assertions in bridge fixture

* fix(rebase): retain OMP resume and fresh launch behavior

* test: align upstream OMP resume expectations

* test(opencode2): verify rejected form closes waiting state

---------

Co-authored-by: Xiro The Dev <lethanhtrung.trungle@gmail.com>
2026-09-19 17:49:03 -07:00
403c0881e1 Bound AI Vault transcript record assembly before allocation (#20963)
* fix(ai-vault): bound incremental transcript record assembly

* fix(ai-vault): skip one oversized record instead of dropping the session

An agent transcript record over the 10 MiB budget threw out of the JSONL
fold, so the whole session vanished from Agent Session History and from
search. A 10 MiB base64 image or a runaway tool result is ordinary.

The reader now discards the offending record up to its newline and keeps
folding. The in-progress record always starts at `consumedThrough`, which
is what makes both its running size and the resume offset past a discarded
span exact; an unterminated oversized tail leaves the cursor at the
record's start so a still-growing record is re-read rather than guessed at.
Skips accumulate on the resume point keyed by start offset, and the scanner
reports them as a per-session `notice` so nothing is silently lost.

The budget itself is unchanged.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:47:56 -07:00
f87359cda6 fix(runtime): persist acknowledged terminal tab retirement (#21020)
* fix(runtime): persist acknowledged terminal tab retirement

* test(runtime): drain tab retirement fixture writes before teardown

* fix(runtime): explain a refused workspace terminal close

The Sleep-workspace path threw the raw refusal enum ("stale-terminal") as an
Error message, which reaches a CLI user verbatim and a Sleep toast via
describeSleepFailure. Map each refusal reason to a sentence instead.

Also pins two behaviours that had no coverage: the user-visible outcome of a
republished stale-terminal refusal on the web client (the caller cannot tell it
from a real close), and the one-call-per-close invariant that keeps a successor
terminal alive.

The bounded close retry was NOT implemented: notifier.closeTerminalTab carries
only a tab id, so a second call destroys whatever successor took that id.

* test(runtime): build refusal fixtures without type assertions

The changed-code quality gate rejects new `as` casts. Replace the
branded-outcome cast with refusedMobileSessionTabClose, and model the
wire-skew reason as a decoded host answer instead of `as never`.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:38:05 -07:00
db7b57b846 fix(claude): enforce history window quota while reading (#21021)
* fix(claude): enforce history window quota while reading

* test: repair history quota audit dependency and CI import

* fix(native-chat): record why restart reconciliation leaves work unconfirmed

Two silent paths hid the cause of an unconfirmed submission. The reconciler's
bare `continue` on an `unknown` outcome dropped the reason it already carried,
and the transcript read swallowed its error, collapsing an oversize file and a
genuine read failure into the same verdict.

Log both. No control flow changes.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:25:36 -07:00
a445abadd4 fix(browser): bound CDP output for stalled clients (#20949)
* fix(browser): bound CDP output for stalled clients

* fix(browser): log CDP outbound overflow before terminating the client

The outbound queue terminated the automation client silently on overflow, so
the client saw a socket close indistinguishable from a crash. Surface the cap
that tripped and the backlog held when it did.

The queue dropped its backlog before invoking onOverflow, so the counters were
already zero at the callback. Snapshot them first and pass them through.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:24:33 -07:00
4d82149fe5 fix(runtime): reject stale inventory after PTY lifecycle changes (#21014)
* fix(runtime): reject provider inventory across PTY lifecycle changes

* fix(runtime): canonicalize SSH inventory generation keys

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:24:29 -07:00
Neil b766f512ec fix(editor): extract diff first-change auto-scroll to a hook to unblock main (#21738) 2026-09-19 17:06:58 -07:00
OrcaWinandm4air b8f67a6266 Close workspace board when selecting sidebar worktree (#21737)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-19 16:54:56 -07:00
Neil 85a3ba6d42 fix(terminal): align CJK IME preedit spacing (#19367)
* fix(terminal): align IME preedit to terminal cell grid

* fix(terminal): preserve native shaping and reuse IME preedit on repaint

* fix(terminal): preserve native shaping with bounded IME spacing runs

* test(terminal): account for inline preedit subpixel rounding

* test(terminal): keep the IME grid fixture wide at every DPI

* chore: regenerate xterm patch after rebase

* test(terminal): remove IME assertion lint findings

* test(terminal): avoid reflective IME fixture access

* test(e2e): run IME renderer matrix with WebGL available

* fix(ci): restore editor line budget
2026-09-19 16:44:38 -07:00
Neil e4c7632db2 perf(terminal): skip kitty scans for plain PTY output (#21643)
* perf(terminal): skip kitty scans for plain output

* fix(terminal): keep the kitty scan fast path total for absent chunks

The new escape-byte fast path dereferences the chunk before the string
concatenation that used to coerce a nullish value, so an unchecked
caller now throws instead of no-opping. Normalize once at the top.

Also type the AgentTerminalPreview connect mock against the real preload
signature, which turns the stale bare-string replay fixture that tripped
this into a compile error.
2026-09-19 16:26:32 -07:00
Neil abd310e5a3 perf(terminal): skip background SGR scans without ESC (#21646)
* perf(terminal): skip background SGR scan without escapes

* perf(terminal): avoid duplicate renderer risk scans

* test(terminal): pin the carried renderer risk scan tail

The foreground renderer-risk scan splices the carried tail onto the
incoming chunk before classifying it, and nothing covered that ordering:
a pre-gate moved back above the concatenation would silently drop the
refresh for a background SGR split across ConPTY chunks.

Also pins the escape-free ASCII path and the shared global SGR pattern's
statelessness across calls, since the background hit returns mid-loop.
2026-09-19 16:25:58 -07:00
3e7da29767 feat(editor): add opt-in collapsed unchanged regions for file diffs (#11955)
* feat(editor): add opt-in collapsed unchanged regions for file diffs

The combined "View All Changes" diff already collapses unchanged lines into
expandable bands (DiffSectionBody sets Monaco's hideUnchangedRegions), but a
single-file diff opened from Source Control renders the whole file. Reviewing
one changed line in a long file means scrolling past everything else.

Adds a General > Editor setting, default off, that applies the same Monaco
option to the single-file diff viewer. Off keeps today's full-file rendering.

The option is always emitted rather than omitted when off: Monaco retains the
last applied value across an options update, so dropping the key would strand
an open diff in collapsed mode after the setting is turned back off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(settings): register collapse unchanged search entry

* fix(editor): keep diff viewer under line limit

* fix(editor): satisfy diff viewer line budget

---------

Co-authored-by: Dan Cieslak <dcieslak19973@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 15:53:28 -07:00
Neil 844e9df98f fix(editor): keep long file paths distinguishable (#21631)
* fix(editor): keep filename visible in long paths

* fix(review): keep diff filenames visible

* fix(review): prevent path metadata overlap
2026-09-19 15:43:45 -07:00