mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
main
801
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a3c6d4266a |
fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27) (#21964)
* fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27)
Native markdown and the native rich editor load images the author referenced
by URL, so the page has to as well or a remote image is a blank where native
paints a picture. `img-src` widens to `img-src 'self' data: https:` on both
platforms; `script-src`, `connect-src`, `object-src`, `frame-src` and
`child-src` do not move.
`http:` stays out, and the pins say so directly rather than by absence: the
Kotlin test's blanket `!contains("http")` could not survive `https:`, so both
native pins now check `http:` (not a substring of `https:`) and check that
`https:` appears in `img-src` and nowhere else, the same shape the `data:`
pin already had.
No behaviour change on released phones: the shell ships in no released tag
(mobile-v0.0.9 predates it), so this reaches devices with the Phase E native
build and not before.
Neither native module has a CI job, so both ran locally: swiftc over the
module plus MobileWebShellChecks, and
`:orca-mobile-web-shell:testDebugUnitTest`. Both were confirmed red against
the old directive first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct what the sealed preview frame is stricter about
The doc comment said the page was deliberately stricter than the native
preview because it loads no remote image and runs no script. Since `img-src`
gained `https:` only the script half is true: the frame loads a remote image
exactly as the native WebView does.
Says instead what an artifact's image URL now is -- a channel that fires on
view and carries whatever its author encoded, with nothing dynamic behind it
because no script runs -- and names `referrerPolicy` as what keeps the
document's own origin out of the request.
Comment only; no behaviour and no test moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): measure both halves of the preview frame's image fence
"fetches nothing of the artifact that leaves the origin" stopped being what
the sealed arm proves once `img-src` gained `https:`. The fixture's foreign
origin is `http://127.0.0.1`, so its two images are refused on the scheme
alone and only the font is refused by `font-src 'none'`. Renamed to say
exactly that.
The half that was missing is an https arm. Playwright route interception
answers an `https://…invalid` origin in the page, so the arm needs no TLS
server and no new dependency, and a request only reaches the handler if the
policy let it out. Under the shipped header, on Chromium and WebKit, the
`<img>` and the CSS background are both requested -- `img-src` governs a
background too -- and the font still is not.
`artifact()` takes the subresource origin; the links stay on the cleartext
one so no existing navigation case changes.
Red-first: with `img-src 'self' data:` put back into the parsed Kotlin
policy, the new arm fails on both engines with `expected [] to deeply equal
[ '/css-bg.png', '/img.png' ]`. The directive was restored byte-identical
before this commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(scripts): split the preview frame's settling out of the render check
The https arm pushed mobile-web-app-html-preview-render.test.mjs to 620
counted lines, over the 600 cap config/scripts carries. Split at a module
boundary rather than bumped: the four wait-and-settle functions are rig
mechanics with no assertion in them, and they now sit beside the diagnosis
module they already reported through.
`waitForLoadedFrame` and `settleAfterMount` are the two the render check
calls; `waitForRecordedNavigation` and `settleWithoutNavigation` stay
internal to the new module.
Move only. Same 20 tests pass on both engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): send Referrer-Policy: no-referrer on the shell document
`img-src https:` gave the page somewhere to send a request, and the document
origin is `orca-mobile-web://<sessionId>/`, so a request that carries a
referrer carries the session id to whatever host an artifact or a markdown
document named.
`referrerPolicy="no-referrer"` on the preview iframe does not cover it.
Measured in the render rig against a permissive control policy: WebKit puts
the embedder's URL on a srcdoc frame's image request despite the attribute,
and Chromium sends none. So the guarantee belongs on the document, where one
header covers every request the page makes, and it rides the document alone
with the policy -- the referrer of a request is decided by the document that
made it, so on a subresource response it would govern nothing.
WKWebView under the custom scheme is unverified: the rig is Playwright
WebKit over http, not WKWebView over `orca-mobile-web://`. The header is the
hedge, and it costs nothing if that host never leaked.
Pinned three ways, each confirmed red first:
- Swift, exit 133 with the header removed.
- Kotlin, MobileWebShellResponseHeadersTest "sends the policy on the
document" FAILED at :17 with it removed.
- The rig, through a new `readShellDocumentHeaders` that parses the Kotlin
source the way `readShellCsp` does and throws rather than returning an
empty map. With the value flipped to `unsafe-url` the WebKit arm fails
`expected [ …(2) ] to deeply equal [ null, null ]`; with the line deleted
the parse throws "could not parse the shell document headers".
The rig's arm carries its own presence precondition: a third server serves
the shipped policy with `unsafe-url`, so the WebKit reading is the header
doing the work, and Chromium's null either way is pinned as the browser's
behaviour rather than sold as evidence the header arrived.
MobileHtmlPreview.web.tsx said the iframe attribute kept the origin out of
the request. Corrected to name the header, since the measurement above is
what disproved it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): quote the current directive where the old text was written down
Three comments still read `img-src 'self' data:`, so a grep for the old
directive found live prose that no longer matches the header. Each stays
about `data:`, which is what those paths rest on; only the quoted policy
changes.
The two remaining hits in the repo are src/main/browser/doc-preview-protocol,
which is the desktop preview's own policy and not this one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the surfaces img-src https: actually unblocks today
The comment justified `https:` with markdown and the rich editor, and
neither renders a remote image on the page. Verified in the tree:
MobileMarkdown paints `` as a tappable link at both of its image
branches and never mounts an Image, and it has no `.web` sibling, so that is
what native does too; MobileRichMarkdownEditor.web.tsx is a 92-line
multiline TextInput, still C7.6's plain source field.
What the directive unblocks today is four surfaces, none of them overridden
on the page:
- MobileAgentIcon's favicon, a hardcoded `google.com/s2/favicons` URL, used
by thirteen callers including the session header and the worktree rows;
- MobileRepoIcon's project icon, a host-named favicon, avatar or upload, on
the worktree list and the host workspace list;
- PRCommentCard's author avatar, from the review reply schema;
- the sealed HTML preview frame, which inherits the policy.
Markdown and the editor are named as the anticipated surfaces ruling 26
points at, so a later reader does not take the loosening as already covering
them. Both native pins carried the same wrong claim and are corrected.
That comment is the only record of why the policy loosened, so it says what
is true now and what is coming, separately.
Comment only: the parsed header is unchanged, checked through the harness
reader the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): point the new source-control route pin at the current directive
Merge resolution, not a conflict git could see. #21957 landed the
source-control and review page routes on main while this branch was open,
and its render check pins the directive text twice: `cspHeader` by substring,
which survives the widening, and the Swift source by the quoted literal
`"img-src 'self' data:"`, which does not. Two PRs green alone, red on the
merge.
Both pins now read the current directive.
One comment goes with it. "Not one request left the origin, so there is
nothing for the policy to have refused" now needs saying why: `https:` is
admitted, so an empty host list is these two closures fetching nothing
rather than the policy refusing something. The avatar that would fetch needs
provider data this page never gets, which the file's own closing note
already explains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): wait for the admitted images before reading their hits
CI's Chrome 152 recorded the CSS background and not the `<img>` by the time
the bounded settle returned, so both https arms failed on a count: "expected
[ '/css-bg.png' ] to deeply equal [ '/css-bg.png', '/img.png' ]" and
"expected 1 to be 2". The reads were absence-shaped -- two frames and 200 ms
-- and the claim they carry is a presence.
So the arms wait for their own evidence, the way the `'refusal'` arm already
does. `frameReady: 'images'` polls until both admitted paths are recorded,
bounded by nothing but the case's own `ctx.signal`. It sits after the marker
wait, because an image is requested by a document that has parsed, and the
arm hands its reader in rather than the settling module reaching for state
that belongs to an arm.
One reader now serves the wait and the reading. An arm that waits on one
list and asserts on another has proved nothing about the list it asserts on.
The `/probe.woff2` absence is untouched and is now an absence standing
behind two presences rather than beside them.
What the wait prints when it does not arrive, captured by making the paths
unsatisfiable against a 12 s case:
[html-preview-render] the arm recorded ["/img.png","/css-bg.png"] of
["/css-bg.png","/img.png","/never-arrives.png"]; #remote
{"complete":true,"naturalWidth":1,
"currentSrc":"https://artifact-images.invalid/img.png?n=n1",
"loading":null}: arm csp=shipped sandbox=product frameReady=images
nonce=n1 | browser 147.0.7727.15 | ... | frames [...]
`complete` with a zero `naturalWidth` is a request that finished and
produced no image; `complete` false is one still in flight. So a Chrome that
never issues the request says which of those it was, instead of a bare count.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): say why an admitted image never arrived, and hand back the context
CI's Chrome 152 read the `<img>` as complete with a zero naturalWidth and a
resolved currentSrc while the route handler never saw the request, and the
CSS background from the same origin did reach it. The diagnosis could say
the image failed but not why, because nothing was watching the request.
Now four sources are, for the `.invalid` origin only, in a module of their
own so the rig file stays under its cap: `request` says whether the page
asked at all, `requestfailed` carries the browser's `errorText`, and CDP's
`Network.loadingFailed` adds `blockedReason` and `corsErrorStatus`, which is
the only place a refusal names itself once the request never reaches a route
handler. `Network.requestWillBeSent` records the resource type, the initiator
and the frame, which separates an image the parser found from one nothing
asked for. They fill arrays while an arm passes and are only read on abort.
Proved by forcing the abort rather than assuming: with the awaited paths made
unsatisfiable, the reading names the font's refusal in both vocabularies at
once, `failed [{"url":".../probe.woff2","errorText":"csp"}]` and `cdp
loadingFailed [{"errorText":"","blockedReason":"csp",...,"type":"Font"}]`,
beside `cdp sent` showing every request's type, initiator and frameId.
Teardown: `open()` now takes an explicit context and closes both the page and
the context in a `finally`. The close used to sit on the happy path, so an
arm whose wait aborted and whose result reads then raced vitest's teardown
left its page and its implicit context open on a browser every later case in
that engine still runs on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct three rationales the widening left wrong
(a) A review comment's avatar is not a surface the widening unblocks.
PRCommentCard renders it only under `Platform.OS !== 'web'` and a component
test pins the skip, so on the page it never renders. Dropped from both native
rationales and moved to the anticipated list beside markdown and the editor,
with the reason each is anticipated rather than current.
(b) The Kotlin rationale quoted the iOS origin. Android serves from
`https://<sha256(sessionId) first 32 hex>.orca-mobile-web.invalid/`, so a
referrer there carries a stable per-session handle and not the id itself,
while iOS serves `orca-mobile-web://<sessionId>/` and carries it verbatim.
Both are something an image host can key on across requests, which is what
the header is for; each file now names its own origin.
(c) "Only the script half of that is stricter than native" overstated it.
`font-src 'none'` and `connect-src 'self'` are stricter too. Images are the
one of the four that stopped being stricter, and the comment now says which
three remain and why.
A fourth, found while checking (a): the skip's own comment justified itself
with `img-src` being `'self' data:`, so a provider avatar would be "one
refused request per card". That is no longer true -- the avatar would load
now -- so the skip is a page capability gap rather than a policy consequence.
Recorded as such at the guard. Whether to lift the guard is a ruling-26
question and not this PR's.
Comments only. The parsed policy and document headers are unchanged, checked
through the harness readers the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): probe why Chrome never asks for the artifact image
CI's read was decisive: on Chrome 152 only the CSS background was requested,
while the `<img>` reported complete with a zero naturalWidth and a resolved
currentSrc. A request that went out and failed cannot produce both readings,
so the next probe asks the frame rather than the network.
On abort it now reads, inside the artifact frame: readyState, the init
script's own moment, document.images.length, every
`performance.getEntriesByType('resource')` name, the navigation entry types,
and for #remote its src, isConnected, complete, naturalWidth, currentSrc and
the outcome of decode(). A resource entry for a URL the rig never saw would
mean the request left the frame and died before reaching it.
Then it issues a `new Image()` at a URL that has never existed and reports two
seconds later whether the rig saw it. That splits the two live explanations: if
the fresh request is seen and the artifact's was not, the frame can fetch and
the parser-inserted element is the cause; if neither is seen, requests from
this frame are not reaching the rig at all. Subframe document commits are
counted from mount, because a second parse is a new window and leaves nothing
behind to count, and a second parse could be meeting a failure the first
cached.
`cdp sent` was empty on CI even for a request Playwright did record, so the
page's own session is blind to the frame. Chromium isolates sandboxed iframes
into their own process, srcdoc included, so flattened Target.setAutoAttach now
puts each child target on the same connection with Network.enable on the
child, and the attached list reports whether the frame is a separate target
at all.
The navigation arm gets the same reading, since CI showed it fails on its own
rather than behind the aborted image arms.
Verified by forcing the abort rather than assumed. Locally the reading prints
one subframe parse, decode resolved, every resource the document fetched, and
`fresh ... issued true seen true`, with the attached list empty, which is
consistent with this Chrome not isolating the frame and its page session
seeing the requests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): time the artifact image against the frame's attachment
CI's second read showed the frame did issue the request -- it has a
resource-timing entry and decode rejected with EncodingError -- while the rig
saw only the CSS background, and a fresh image created later from the same
frame was both issued and seen. The remaining question is whether the entry
starts before anything was listening to that frame.
So the entry is now reported in full for the element under test:
responseStatus, transferSize, encodedBodySize, nextHopProtocol, startTime and
duration. A zero status with a zero transferSize is a fetch that reached the
network stack and came back with nothing, which is what an unintercepted
request looks like once `.invalid` fails to resolve.
Both sides of the comparison get a wall clock: `Target.attachedToTarget` and
Playwright's own `frameattached` now carry the moment they fired, and every
recorded request carries the moment it was seen. An entry that starts before
the attachment is the race stated rather than inferred.
Abort path only; the passing run is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): serve the artifact's https assets from a real TLS listener
Interception could not measure what the directive admits. Chrome 152 isolates
the sandboxed srcdoc frame into its own target and the parser-inserted `<img>`
is the document's first fetch, issued before interception attaches there: the
request escaped to the real network, `artifact-images.invalid` did not
resolve, and the rig recorded nothing while the frame's own resource timing
showed the fetch and a later fresh image was both issued and seen.
So the assets come from a listener that is already accepting before the page
exists. It cannot be raced: the request arrives or it does not, and either
answer is the measurement. Hits and referrers are recorded server-side, the
way this rig's cleartext origin already does it, and read per arm by nonce.
`img-src 'self' data: https:` matches on scheme, so `https://127.0.0.1:<port>`
exercises the same directive as any other https host.
Lifecycle: started in beforeAll before any browser, closed in afterAll beside
the other servers. Its certificate is generated per run by openssl into the
suite's own scratch directory under `mobile/.tmp`, which the root gitignore
already covers and into which the server writes a second `.gitignore` as well;
the key never leaves that directory and nothing trusts it, since the context
is created with `ignoreHTTPSErrors`. No arm shares state: one hit list keyed
by each arm's nonce, and the permissive-Referrer-Policy control stays what it
was, a second bundle server serving the page, because the control is the
document's header and not the image host's.
The navigation record moves off interception too. It is now `page.on('request')`,
one subscription over every frame, armed after the rig's own `goto` exactly
where the route used to be registered; the route stays only for what only a
route can do, refuse the navigation. That answers the top-nav arm's `recorded
[]`: its record depended on the same per-target interception.
And the arms stop swallowing their clicks. `click(...).catch(() => {})` made a
tap that never landed and a tap that produced no navigation the same empty
counter; `open()` now records the error and the two top-nav arms assert it is
null before reading any count.
One correction to the reading added in the previous commit. The resource-timing
fields came back zero for a request that had plainly succeeded: they are opaque
cross-origin. The listener now sends `Timing-Allow-Origin`, after which
transferSize, encodedBodySize and nextHopProtocol carry real values.
`responseStatus` still reads zero on a successful request, so the comment names
the three that discriminate rather than the four that are printed.
24/24 on both local engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the artifact fetch and the attachment on one clock
The early-or-late comparison spanned two clocks and could not answer the
question it was written for. Every `at` in the request log is Node's
`performance.now()`, counting from process start; the resource entry's
`startTime` is the frame's own, counting from that document's navigation. A
frame entry reads as earlier than a Node attachment by roughly the process
uptime, so the comparison would have reported the race as confirmed on every
run, including runs where there was no race. A green CI would not have caught
it.
So the comparison is stated where both numbers actually live: `asked` against
`attached` in the request log, on the Node clock alone. `startTime` and
`duration` stay, labelled as the frame's own account and explicitly not
comparable to an attachment time. The module docstring says the same, so the
next reading added here starts from the rule rather than rediscovering it.
The commit message of
|
||
|
|
6ed961b322 |
test(mobile): scan for the params contract before parsing, so the boundary census fits its budget under load (#21970)
* test(mobile): read only the files that name the params contract, not all 2,235 (OTA phase C) `keeps every mobile import of the params contract type-only` timed out at 5,000 ms in Mobile Checks on #21964 and once in the lead gate on #21969, while passing alone in about a second. Measured here rather than guessed, on this tree: | step | cost | | ------------------------------------- | ------ | | walk `app` and `src` (shared walker) | 4 ms | | read 2,235 files, 11.3 MB | 42 ms | | parse every one of them | 392 ms | | visit every resulting AST | ~650 ms | Exactly one of the 2,235 reaches the contract. The case was parsing and walking the tree of the other 2,234 to find that out, and under full-suite load it had 4,216 ms of its 5,000 ms budget gone — 784 ms of margin, which is the intermittency. The walk was never the problem and is unchanged: `censusSourceFiles` already excludes the generated files that took this case from 1.5 s to over its budget in the first place. What is new is a first stage between the read and the analyser. `ts.preProcessFile` is the scanner behind `tsc`'s own dependency discovery: it reports every module reference — the same four shapes the analyser inspects — without building a tree, and only the files it names as reaching the contract are parsed. A substring search over the text would have been faster still and wrong: a specifier spelled `rpc-contract` resolves into the contract and does not contain its name. The scanner decodes it, and that shape is now one of the cases, asserting its own premise rather than claiming it in a comment — its text does not contain `rpc-contract`, and the filter admits it anyway. Two rules keep the new stage honest. Every shape the analyser flags, plus the three type-only spellings and the escaped one, must survive the filter — a narrowing to `includes('rpc-contract')` reds that with five shapes lost. And the set the filter hands on must be non-empty, because `[]` offenders is also what a census reaching nothing at all prints: pointing `contractRoot` at a renamed directory now reds all three cases instead of passing. Verified still a fence: a planted value import of `repo-params` in `src/session` is reported by name. 4,216 ms to 319 ms under the full suite, 1,103 ms to 189 ms alone. No timeout was raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the boundary census's import spellings The anti-slop gate reads `shapes` as structure rather than ownership, and it is right: the array holds the spellings of one contract import, which is what the case is about. `contractImportSpellings` says that, and the `it` title and the comment below it follow. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): admit namespace re-exports the pre-filter cannot see `ts.preProcessFile` does not report `export * as ns from '...'` or its `export type * as ns` form. It reports `export *`, `export { X } from`, `import ns = require()` and the rest, which is what made the filter look sound. So a mobile file re-exporting the params contract under a name was dropped before the analyser saw it, and the census passed empty on exactly the shape its soundness case exists to catch. Red first: both spellings added to the admitted list failed the case, naming themselves. The filter keeps the cheap path and adds the one shape the scanner omits, matched on its own spelling rather than on the contract path. A text match for the path is the wrong widening and this file already holds the counterexample: the escaped-specifier case is a real import whose text never contains the directory name, so a substring fence would miss it while looking thorough. The cost is bounded because the shape is rare, counted rather than assumed: across the 2,236 files this census reads, the only match is this file, through the fixture strings themselves. No product file uses it, so the widening parses nothing that was not already parsed. The timeout this PR exists to fix is unaffected. Under full-suite load the slow case runs in 1179 ms against its 5 s budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find namespace re-exports by token, not by text `export * /* note */ as ns from '...'` is one namespace re-export and the regex did not match it, so the pre-filter still dropped a file the analyser needed to see. The same sequence split by a line comment is the same hole. A text rule reads the characters between `*` and `as`; the language does not. Red first: both spellings added to the admitted list failed the case, naming themselves. The rule is TypeScript's own scanner now, walking for `export`, an optional `type`, `*`, `as`, restarting from any `export` that breaks the sequence. Comments are trivia and the scanner skips them, which is the whole reason for the change. Counted rather than asserted, over the 2,236 files this census reads: 1,005 carry an asterisk and reach the scan, which costs 118 ms for all of them together; exactly one matches, this file, through its own fixtures. The docstring says the `*` pre-check is not a rarity filter — a block comment carries an asterisk, so most files with any comment reach the scan — rather than implying the shape is rare enough not to matter. The timeout this PR exists to fix is unaffected: under full-suite load the slow case runs in 594 ms against its 5 s budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the namespace re-export scan in sync across template substitutions A bare scanner loop loses its place after `${...}`. The closing brace has to be re-scanned as a template token, or everything after it is tokenised as ordinary source and the rest of the file is read wrongly — so a file with an interpolated template anywhere before a namespace re-export was dropped again, which is the hole this filter exists to close. The loop keeps `preProcessFile`'s own stack: push on `TemplateHead`, push and pop plain braces inside a substitution, and on the brace that closes one re-scan, popping only for a tail. Tokens inside a substitution cannot be this declaration, so the matcher rests there and the work is staying in step. Red first: the interpolated-template spelling failed, naming itself. Two more fixtures pin the stack rather than a flag — a brace inside the substitution, and a template inside it. The braced one was red too; the nested one passed before and is here because otherwise nothing exercises that branch. Twelve shapes were probed against the new loop, including an unterminated template and an `export * as` written inside substitution text, which must not match. Counts move, and the new number is the right one: 1,005 of 2,236 files reach the scan, costing 173 ms together, and none matches — including this file, whose spellings sit inside template literals and are template text rather than tokens. The previous count of one was this file matching through its own mis-tokenised fixtures. Under full-suite load the slow case runs in 479 ms against its 5 s budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5d51bcaf6e |
feat(mobile): serve any manifest route through a [...page] catch-all (OTA phase C, C8) (#21950)
* feat(mobile): add the page-route-unavailable refusal screen The catch-all route landing next has no native screen behind it, so its fallback cannot be a panel. Nothing imports this yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move firstParam out of the source-control tree Every caller is a route file under app/h/, and the import dragged mobile-git-status.ts and the screen-state module into the closure of any route that reads a param: 1989 modules (3 local) for a one-line helper, against 1 from src/navigation/route-param-reader.ts. Pure move. The three shell route suites drop their lucide-react-native mocks with it; that barrel was only ever reached through the old home. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve any manifest route through a [...page] catch-all Every page screen needs a route file under app/h/[hostId]/ today, so a screen the desktop registers after a store build has nowhere to mount and expo-router paints Unmatched. This adds one catch-all that hands any host-scoped pathname to the shell; the manifest still decides, through the same routeViewOf the other switches reach. Measured with expo-router's own matcher on both platforms: every route that has a file keeps it, index and the four .web.tsx siblings included; only pathnames that reached Unmatched move. The body lives under src/ because expo-router 55 reads a file's platform from the first dot of its stripped name: [...page].web.tsx under app/ parses as platform '' and registers a second route rather than overriding the first. Under src/ the stem is plain and Metro and the page builder both resolve the sibling. getRoutes shows exactly one [...page] key on each platform. Registers no manifest entry, grant, hop row or PAGE_SERVED_SCREENS row. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page refuses an unrouted host path instead of Unmatched The catch-all owns every /h/<id>/... pathname with no module, on the page as well as in the app, so the render check's unmatched case can no longer pass: measured in headless Chromium, /h/<id>/not-a-route paints the refusal with no page or console error. bridge-caps.ts records that C8 closes the C1.7 class for the host subtree, and its dot-segment note is rewritten to the measured mechanism: getStateFromPath normalizes the href through new URL(href, 'file:') in getUrlWithReactNavigationConcessions before cleanPath sees it, so /h/..?x lands on the app's root screen rather than on a host screen with hostId '..'. Refusing it stays correct. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the web-overrides allowlist in append order The catch-all entry was added with a whole-file sort, which rewrote 116 lines for one addition and buried it. The test compares sorted sets, so the order on disk is free; append order is what makes the diff readable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): leave the refusal on the encoded host route The Back href was a raw template, so a host id carrying a slash built /h/a/b — two segments, which the catch-all that rendered the refusal matches with hostId now "a". The control looped back into the screen it exists to leave. hostStackHostRoute already encodes it and is what the notification path pushes through. hostId is a string: firstParam returns one, so the undefined arm and its ?? '' were unreachable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the catch-all's fallback binding and its way out catch-all-page-route.test.tsx mocks both the shell and the refusal, so fallback={refusal} was unpinned: mutating it to null left that file at 10 passed. This drives the other half with the real shell screen and the real refusal under it, stubbing only what the session reducer settled on. Four cases, each measured against a mutant: fallback={null} reds three, push instead of replace reds two, a raw /h/${hostId} template reds the five-shape encoding case. The checking case is the presence precondition the rest need: before the flag read settles the switch returns the refusal on its own, with the same text and the same control, so an assertion on the refusal alone would pass against a screen no shell ever rendered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the route-body follower resolve every shape or fail The follower read only `export { default } from 'x'`. A route file doing `import X from 'x'; export default X` and mounting the shell without shellScreenRoute left the census at 5 passed: the body was never opened, so the file read as "not a switch" — the one answer a census must never give by default. Both shapes are followed now, and an unresolvable one is named rather than skipped. Measured against four mutants: a shellScreenRoute call dropped from the re-exported body reds the rule; an import-then-export route mounting the shell reds two cases naming the file; a default from a package specifier, a file with no default, and a re-export with no module each red the new resolution case with the reason. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): type the host-node lookup in the catch-all state test The tests-typecheck ratchet reds on findAllByType with a host string: react-native is mocked to strings here, which is not an ElementType. A findAll predicate on node.type is the same lookup and checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why the refusal does not absorb the other shell states The refusal answers one question — this build cannot serve this route — and offline, checking and the protocol wall answer different ones that are each true for a screen only the page has. Absorbing them would tell someone with no connection that the screen does not exist. Written where fallback is bound, and driven: an offline session through the catch-all paints the connect message. Mutating the shell to return fallback for offline reds that case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the type the shell-view probe stands in for The anti-slop gate refuses a broad `object` parameter, and it is right here: the probe forwards every prop to its host node, so the type it accepts is the view's own. Type-only import, so the module's requireNativeViewManager call is still never evaluated. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send the refusal to the app root when it has no host firstParam answers an absent hostId as '', so Back called hostStackHostRoute('') and landed on /h/ — which expo-router's own matcher resolves to the h layout with no child, a press that paints nothing and leaves the dead end in place. The app root lists hosts and is where ProtocolBlockScreen sends the same gesture from the same position; the label follows the target rather than outliving it. Also through useRouteHandoff rather than useRouter, which is the same defect on the other side: the page renders this screen through the catch-all's .web.tsx sibling, and there a bare replace navigates inside the WebView to a route the page does not carry instead of leaving it. ProtocolBlockScreen already uses the seam; the router-seam censuses cover src/session, src/files and src/source-control, not src/mobile-web-shell, so nothing caught it. Costs one module in the page closure (host-stack-navigation.ts): every module the seam reaches is already in the layout's closure. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): repoint C4.4's source-control web sibling at the moved reader The merge brought in a route file that imports firstParam from the source-control screen state, which this branch emptied. Git merged both sides cleanly because neither touched the other's lines; tsc is what catches it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): mock dictation's device half in the catch-all state test The merge put the audio verbs in the shell screen's closure, so this file reaches @orca/expo-two-way-audio, whose module touches the Expo global at import. Same two mocks MobileWebShellScreen.test.tsx carries for the same reason; what each verb does is bridge-audio-verbs.test.ts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the router seam under mobile-web-shell The three landed censuses walk src/session, src/files and src/source-control, which is how PageRouteUnavailableScreen shipped with a bare useRouter and nothing caught it until CodeRabbit. This tree needs a shape of its own because it holds both halves: the rule cannot be "no router" when MobileWebShellScreen and useShellStackPop are the app end the page's navigate and navigate-back notifies arrive at. Both are named with the reason, and neither has a .web.* sibling, so neither runs inside the page. Red first: with the bare useRouter put back, two of the four rules fail naming the file — + "PageRouteUnavailableScreen.tsx (useRouter)" - "PageRouteUnavailableScreen.tsx" The walk covers 72 product modules, asserted above 60, so the empty finding list is over a non-empty walk. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d9954000b3 |
refactor(mobile): the rich editor's document becomes scope-threaded modules and a bundled factory (OTA phase C, C7.10 C1) (#21969)
* refactor(mobile): split the rich editor document's stylesheet and markup apart The body constant carried the tail of a `:root` block, every CSS rule and the editable surface's markup in one string, which only the HTML builder could splice. A page mounting the document needs the stylesheet and the markup separately, so they become a function over the theme and a constant. Byte-for-byte inert: `mobile-rich-markdown-editor-document.test.ts`'s digest of the shipped document is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the keyboard-inset normaliser its own module It is the host's half of the inset, read by the controller, and it sat in the module holding the document's in-page script. The script is about to become ordinary TypeScript under `rich-markdown/`, where a native-side normaliser does not belong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich editor's document becomes scope-threaded modules and a factory The editor's ~600-line program lived in seven string constants a concatenator glued into one `<script>`: unreadable, untypeable, and unreachable from a page, which is where the OTA shell has to run it (ruling 26). It is now ordinary TypeScript under `src/components/rich-markdown/`. Every function that touches editor state takes `scope: RichMarkdownEditorScope` first, `createRichMarkdownEditorDocument(host)` builds the scope, runs the start sequence and returns `{ send, stop }`, and the six window reads the script did are host seams with those reads as their defaults: `postToHost`, `promptForUrl`, `keyboardInsetSource`, `clearTimer`, `getSelection`, `getDocument`. `runCommand` is async because a host that answers the URL prompt with a modal cannot answer synchronously; the thirteen commands that never wait stay one synchronous act. No module holds a `let` and none does work at parse time (rulings 20, 21), so a second mount starts from its own state and `stop` takes back both the surface's four listeners and the viewport's two. The native document is an esbuild IIFE bundle of `native-document-entry.ts`, written beside the terminal document's artifact by a fifth postinstall generator. Nothing ships it yet: the HTML builder still splices the old strings, which the next commit changes. Red-first: `rich-markdown-document-parse-time.test.ts` and `rich-markdown-host-seams.test.ts`. Their readers are the terminal census's, extracted to `src/test-support/webview-document-census.ts` and pointed at both documents rather than copied. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): ship the bundled document and retire the editor's script strings `buildMobileRichMarkdownEditorHtml` splices the esbuild bundle of `src/components/rich-markdown/`, and the seven string constants and their concatenator go. `escapeInjectedJavaScriptString` stays: it is the escape for `injectJavaScript`, which is still how the native host reaches the document. Equivalence, since a byte golden over the script cannot survive a bundler: - `rich-markdown/native-document-bundle.test.ts` evaluates the shipped artifact exactly as the WebView does — its markup, its bridge, its `execCommand`, its `prompt`, its `visualViewport` — and drives it through the injected handle: `keyboardInset` then `ready`, all five members, a markdown round trip through the real escape, an edit under the host's generation, every toolbar command's engine verb, the `javascript:` refusal, a tapped link, and the module list. - `mobile-rich-markdown-editor-document.test.ts` keeps a byte pin, now over the page around the document. Measured on main's own document with its script region removed and on this one: 5,621 bytes, both `5054e1d5c87e4ce1805d4856ddc8bf36804e697675e6013d84da453d3e81af25`. The whole-document digest it replaces was `1ef29c88…`, 29,852 bytes. Every assertion `mobile-rich-markdown-editor-html.test.ts` made by extracting functions out of the emitted text is kept, aimed at the modules: - nested/ordered/task list rendering and serialization, entities, explicit numbering, the parent-start fallback, read-only checkboxes → `markdown-round-trip.test.ts`, over real elements rather than shaped objects. - the emitChange/setEditable guards and the generation carried through a replacement → `editor-content.test.ts`, behaviourally. - dismissKeyboard, the tapped caret, the label tap, the restored caret, the end-of-document fallback, the detached caret → `editor-selection.test.ts`, with a blur that drops the ranges the way WebKit does. - parseable script and the injection escape stay in the HTML test. New with the factory: `document-lifecycle.test.ts` — stop takes the four surface listeners and the viewport observer off, a second mount is its own document, two documents do not share `editable`, and a start that throws unwinds. `use-mobile-rich-markdown-editor-controller`, `MobileRichMarkdownEditor` and the web fallback tests are untouched and green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the document's mutable bindings from the tree, not the line start The census matched `/^(let|var) /gm`, so `export let`, a declaration indented inside a top-level block and a `for (let …)` head were all invisible — three shapes of the one binding two documents would share — and its single precondition proved only the shape it could already see. `moduleLevelMutableBindings` walks the program instead and stops at every function body, because a binding one call owns is not module state. Its preconditions are one per shape, with the kind each reports, and a negative case over a `const` and a function-local `let`/`var` so the empty list is a measurement rather than a reader that refuses everything. Red-first: `export let pendingReport = 0` planted in `keyboard-inset.ts` reds it with `keyboard-inset: let pendingReport`, which the old matcher passed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin both WebView document bundles to the mobile root esbuild writes each module's path into a bundle as a comment relative to the working directory, and neither generator set `absWorkingDir`. So the artifact's bytes followed the cwd of whatever postinstall run wrote it: measured from the repo root, `mobile/`, and `mobile/src`, three digests — and from outside the repo the comments carried `/Users/<name>/…`, a machine path in the one file every bundle test compares against a build it makes itself. Both generators now pin the mobile root, so the four cwds measured agree, and both bundle tests carry the pin: a digest built in a child process from the OS temp directory equals the committed artifact's, and no comment in either artifact is an absolute path or climbs out with `../`. `build-terminal-document-script.mjs` had the defect verbatim on main; C1 copied its shape, so both are fixed here rather than leaving the original to be found again. Neither artifact's bytes move: both were generated from `mobile/`, which is what `absWorkingDir` now names. Red-first: deleting the `absWorkingDir` line from either generator reds that generator's case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the getSelection seam's override, not just its default Five of the six seams had both halves and this one had only its window default, which is the half that cannot fail on the page: there the caret has to come from the object the host hands over, because a document mounted inside a screen shares `window` with every other field on it. The case gives the document a selection of its own, blurs the surface the way WebKit does — dropping the ranges, which is the whole reason a caret is saved — and reads the restored caret back out of the host's object. The window's own selection stays empty throughout, which is what says the default was never consulted. Red-first: `rememberSelection` reading `window.getSelection()` instead of the field reds it; every other case in the file stays green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the editor document's stop cancel its pending timer `stop` took the surface's four listeners and the viewport's observer off and left the input timer, while the scope kept the handle and the `clearTimer` seam kept the means to cancel it. A listener comes off with the element it was on; a scheduled callback holds the scope and fires into a document the host has already unmounted, posting a change under the generation of content it has replaced. `stopEditorContent` cancels it through the seam and clears the field, and the sequence runs it last — after the listeners that could have scheduled another one are gone. Nothing schedules the handle today. The cancel is here because the seam and the field exist for the day something does, and that is not the moment to discover `stop` never reached it. The case plants the pending change rather than waiting for a debounce, and carries its own control: the same timer posts while the document is running, and posts nothing once it is stopped. Red-first: dropping `stopEditorContent` from the sequence reds both that case and the parse-time census's start/stop set comparison. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the postinstall generator count in both censuses Two comments said four generators and six generated files. There are five generators writing six files, and the six are not the six either comment described: `census-source-files.ts` still named the page's copy of the terminal document, which ruling 25 retired and #21962 stopped ignoring, while C7.10 C1 added the rich Markdown editor's. Both now name the lists of record — `mobile/package.json`'s postinstall for the generators, `mobile/.gitignore` for the files — and say the count is a reading that grows rather than a fence, which is what made the old numbers wrong twice over. Verified against both lists: 5 and 6. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which digest is the document and which is the page around it The docstring put main's whole-document digest and byte count in the sentence introducing the shell pin, so it read as if `1ef29c88…` and 29,852 bytes were what the constant below asserts. They are not: that digest is of main's whole document, script included, and nothing in the file reproduces it. The constant is of the document with its `<script>` region emptied, taken on main's document and on this one. Both are now named and separated, with what each covers and why the shell one was read twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the parse-time fixture by its role Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an editor command whose dialog answered after the host moved on C7.10 C1 made `runCommand` async so a host can answer the URL prompt with a modal. Inside the WebView that changes nothing — `window.prompt` resolves within a microtask, and the host reaches the document through `injectJavaScript`, which is a later task — but on the page the modal is a real task boundary, and while it is open the host can replace the content, make the editor read-only or unmount it entirely. The continuation ran anyway: `createLink` against markdown nobody chose, and a change posted under the new generation carrying an edit made against the old one. `acceptsCommands` is the question both halves ask: not stopped, still editable, still the same generation, still contenteditable. `insertUrl` asks it before `execCommand` and `runCommand` asks it again before emitting, each against the generation read before its own wait. The scope gains `stopped`, which `stopRichMarkdownEditorDocument` sets. Inert on native, where no state can change across a microtask, so the answer to both questions is the one the old code assumed. Red-first: with either check removed, the new case reports `[ 'createLink', 'createLink' ]` against `[ 'createLink' ]`. The case carries its own control — an answer that arrives while nothing has moved is still applied and still reported. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the editor's block reader always consume a line `markdownToHtml` looped forever on `# `, `- ` and `1. `. `isBlockStart` admits a marker followed by a space, and the list test admits the same, but the heading reader requires text after the hashes and `parseListLine` requires text after the marker — so on those lines the list branch consumed nothing and returned the index it was given, and the paragraph loop gathered nothing and pushed an empty paragraph without advancing. A one-line file the host handed to `setMarkdown` froze the WebView. Two guards, both by the same rule: a branch may only commit if it moved the index. The list branch falls through when its run is empty, and the paragraph falls back to the line itself when it gathered none. Present on main verbatim, so this is inherited rather than introduced — but the fix is observationally inert, because the only inputs it changes are the ones that previously never returned. Every input that produced output produces the same output. Evidence, from a probe that bounds the loop from the inside rather than waiting on it: before, `# ` and `- ` both UNBOUNDED; after, twenty marker and fence shapes all return. The pinned cases carry their own control, `# ok` and `- ok`, so the fallback is not swallowing the readers it falls back from. A red-first case is not possible here: without the fix the case does not fail, it hangs the worker. The probe above is the measurement. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): see every declaration that runs as a document module is evaluated The parse-time reader inspected only variable declarations, while `DECLARATION_KINDS` admits classes and default exports. So `class A { static value = install() }`, a static block, and `export default install()` all passed a census whose whole job is to refuse exactly that — and a static field reading `document` passed too, which is the remount defect the rule exists for, wearing a different shape. Three shapes now, each reported by what it does rather than what it looks like: a variable initialiser, a class's static members, and a default export that is an expression. `DECLARES_WITHOUT_RUNNING` keeps the last one from walking into the body of `export default function () {}`, whose calls run when something calls it. The preconditions are one per shape, with a negative case beside them: an instance field runs per `new` and nothing in a document is ever constructed, and a default-exported function declares a body rather than running one. Inherited from the terminal's census, which had the same reader; both use this one, and both are green. Red-first: removing the class branch reds the new precondition case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read lifecycle exports from the tree, not from one exact spelling The reader was a regular expression needing `export function`, one line, the scope parameter and no return type. `export async function startX(`, a return type, or a parameter list the formatter wrapped made a real lifecycle export vanish — and the comparison it feeds is a set against the names the sequence calls, so a function missing from *both* lists makes them agree. A start nobody runs would have read as a start nobody needs. It now qualifies a function by what it is: exported, named for its lifecycle, and taking the document's scope as its only parameter. That last clause is ruling 20's own wording — a start takes nothing the scope does not already carry — and the regex was enforcing it by accident, through the single parameter its pattern happened to allow. Surfaced by the change: the terminal's `startEdgeScroll(scope, dir)`, which the regex never matched and the sequence never calls. It takes a direction, so it is the overlay's act for a drag rather than a module's lifecycle, and the one- parameter rule refuses it for the stated reason instead of by accident. Both censuses are green. Red-first: restoring the regex reds the new precondition case, which covers `async`, a return type and wrapped parameters, with refusals beside them for a two-parameter start, another document's scope type, and an unexported function. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a91ca8b19e |
feat(mobile): dictation on the OTA page over native audio verbs (Phase C, C7.10 PR D) (#21905)
* feat(mobile): serve dictation capture over four native audio verbs The page owns dictation's state machine and speaks `speech.dictation.*` to the desktop, where transcription runs; the microphone is the shell's. So the shell gains `native.audio.start|read|stop` and `native.wakelock.set` — four rows, four grants — and rings what the microphone produces at the page's own pending-audio budget rather than pushing bytes the page would hand straight back. `native_audio_not_capturing` joins the refusal vocabulary: a read for a capture this session does not have is the one refusal the page must tell from a device that failed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): take dictation capture through one seam on both hosts `use-mobile-dictation.ts` held the microphone and the wake tag directly, so the page had a hook whose every device call was a stub answering denied. The five calls move behind `src/platform/dictation-capture.ts`: natively the same calls in the same order, on the page the shell's four verbs, with the drain raising the events the engine emits. The tag bookkeeping stays where it was and stops importing `expo-keep-awake`: two calls come in through the seam and the pools, the queue, the timeouts and the retries are the same on either host. The chunk sender is untouched. A chunk carries raw PCM because that is what the budget counts and what `speech.dictation.chunk` is built from, so the page pays one decode of 32 KB a second rather than the flow carrying two shapes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): drain the shell's audio ring on a 500 ms batch One `speech.dictation.chunk` per native microphone event is 31.25 forwarded requests a second, and each holds one of the bridge's 64 in-flight slots for a whole desktop round trip. Measured over ten seconds against a two-second link: 63 in flight at the peak and one slot left for the rest of the page. Drained every 500 ms instead: 5 in flight, 60 slots free, the same 42 KiB/s, and 38 frames out and 34 back for the whole session. The frame cap was never the bound — half a second of PCM is 3.3% of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): derive dictation's grant rows from the route closure Which routes need the four audio verbs is a census, not a hand list: the rule reads each registered page route's own closure and holds its `grants` to what that closure reaches. Vacuous on today's route list — the session route is the only closure carrying the seam and is not registered yet — so a control runs the same rule against the session module and names all four as missing. The closure also records what the seam took off the page: `@orca/expo-two-way- audio` and `expo-keep-awake` are gone from it entirely, and removing the web file puts four of the vendored stub's modules back. The mic control's render case found a real one. A start the shell refused outright left the button on "Starting voice dictation" with no way back, which is every tap on a route without the grants. It reports the refusal and returns to idle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the mic control's render case inside the tests typecheck A `let` the renderer assigns inside a callback narrows to `never` afterwards, and the mocked `Pressable` took `children` as `unknown`. Both are type-level only, and the ratchet is the gate that notices: a test outside `tsc` can pin a shape that stopped existing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand over the audio still in the ring before stopping the shell `end` cancelled the drain and stopped the capture without a last read, so up to one drain interval of the utterance's tail — 16,000 bytes, the 400 ms a user is still speaking as they lift the button — was discarded on every stop. The reviewer's probe spoke 12,288 bytes in the last 400 ms and the page delivered none of them. Natively that audio is already in the hook's hands, so this was a page-only loss of the end of every sentence. `end` is now asynchronous: it cancels the timer, waits for any read in flight, reads once more, and only then stops the shell — stopping first takes the capture away and the read after it is refused. `stop()` awaits it before it stops accepting chunks and before it takes the pending set, or the tail would be dropped one line later and `finish` could overtake the last send. A release still skips the last read: the screen is going away and there is nobody left to hand the tail to. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): release the page session's wake tag when the session ends The wake-lock server held its tags per instance and a new one was built per page session with nothing ever disposing it, so a tag a session took was never given back and the screen stayed awake for the app's lifetime. The page is a document that can navigate, fault or be swiped away mid-dictation, so nothing else was ever going to call deactivate. Its own docstring claimed the opposite. It now answers `{ serve, dispose }` and is disposed with the session exactly as the microphone and the staged media handles are. `dispose` drops only what is still held, so a tag the page already gave back is not deactivated twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the capability flag no screen reads `canCaptureAudio` answered four grants to nobody and had no test. The fence that actually holds is the per-verb `ungranted` check every member already makes before a frame is sent, and the mic control's render case pins what a screen does with it. The surface's member list is pinned instead, so the next flag with nothing behind it has to be added there on purpose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-measure the closure the dictation census records, and make its controls real Three corrections to the census, all of them about the census lying rather than the product being wrong. The recorded number was four modules; measured on this head it is eight — five from `@orca/expo-two-way-audio` and three from `expo-keep-awake` — for a net +7 once the local file that left is counted. The absolute closure counts are provenance in the docstring and are not asserted, because every merge of main moves them. The absence now has a precondition: both package names are resolved from the install, so a substring matching nothing fails as a typo. The case named "reads the census file" read no file. It reads the shell's own verb table through `import()`, behind the closure guard, so a grant the shell has no row for reds instead of agreeing with itself. And the grant control re-implemented the rule's filter inline. Both the rule and the control drive one function now, over the entry C7.7 would write if it copied its neighbours' grants. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serialise the shell's audio starts and stops Two starts racing the OS permission prompt — a page reloaded while it is up, which is the case the replacement rule exists for — both reached `listen()`, and the second overwrote the first's handlers without removing them. The engine went on calling into a capture nobody could read, and `dispose` freed one of the two. A stop that overlapped a start found nothing to end and the start opened a microphone behind it. Starts and stops now run one at a time in the order the page asked for them, and a start that comes back after the session ended opens nothing. Reads stay off the queue: they must not wait behind an opening capture, and a read with no capture is already a refusal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): end a capture on the same two interruptions on both hosts The page treated any interruption as the capture being taken away, while the native seam has always gated on `began` and `blocked`. So an `ended` on its own — the OS handing the session back after a notification chime — cancelled a live dictation on the page and did nothing natively. The rule is now one predicate beside the vocabulary it belongs to, read by all three places that decide it: the shell, which stops filling its ring; the native seam, which raises it off `onAudioInterruption`; and the page, which raises it off a read reply. `recording` still ends the page's capture whatever the kind, because a capture the shell no longer has is gone however it went. The native half had no test of its own, which is why the drift was invisible. It has one now: the five calls it makes, the chunk it hands over, the wake tag, and which interruptions end it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): type the chunk sender as what its callers pass The sender's parameter was the native `MicrophoneDataEvent` though both callers hand it a `DictationCaptureChunk`. Structurally the event is the wider type, so it accepted either and read `droppedBytes` off neither — a page whose audio the shell's ring had dropped would have sent it as though nothing were missing, and nothing would have failed to compile. Typed as the chunk, with a compile fence beside the seam holding both directions: a chunk is accepted, an event is refused, and a raw buffer is not a chunk. The seam normalises the bytes, so the widening the sender did has no caller left. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the session closure with dictation's page modules on it The capture seam moves this count down rather than up. Measured at |
||
|
|
23207bfde2 |
feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) (#21957)
* refactor(mobile): move the review route body onto a component and the handoff seam (OTA phase C, C4.4) The review route file called `useMobileDiffReviewController` at its top level. A switch cannot keep it there: hooks are unconditional, so the whole controller — its client subscriptions included — would run behind the shell's page whenever the shell renders. As an element passed for `fallback` it is created and not mounted, which is how the explorer switch already behaves. `useRouter` becomes `useRouteHandoff` in the same move. It was the one raw expo-router router left in the review closure (measured: the only other value import of one is the seam's own web sibling), and inside the page the session screen `openSession` replaces to is native, so that target has to be handed back to the app rather than posted into a document that does not render it. The params are read in the component rather than handed down, so this is the route body and the route file above it is free to become a switch. `session-router-seam-census.test.ts` gains the module by name. Kept with `useRouter` the census reds twice — `imports nothing from expo-router that can navigate` names `MobileDiffReviewRouteScreen.tsx (useRouter)`, and the completeness case gains `useRouter` — which is what forces the swap rather than leaving it to a reviewer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): switch the source-control and review routes to the shell, still unregistered (OTA phase C, C4.4) Both take the files switch's shape: `firstParam`/`firstReviewParam` on every param, `shellScreenRoute` as the one predicate, `MobileWebShellScreen` keyed on `shellScreenRouteKey`, the native screen built as an element and passed for `fallback`. Both gain a `.web.tsx` sibling for `index.web.tsx`'s reason — the native file reaches OrcaMobileWebShellView, whose module throws at import in a browser, and the route manifest imports every route. Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it, which is the next commit; until then the flag is the only thing that changes and it is off. Query params are omitted rather than sent empty, and the whole record is omitted when none was named: `tab=` is a lens named nothing and lands on `changes` through a different branch than an absent one, and the same holds for `name`, `origin`, `scope`, `file` and `area`. `pr` and `history` are deliberately not switched. Both are `Redirect`s into `source-control`, and a redirect inside the page would leave the session bound to a pathname the page has left; left native they replace into this route and its switch mounts the shell. Three censuses red without their rows, measured on this tree: - `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` names the two new siblings; `states a reason for every override` reds on a placeholder under 20 characters. - `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no others` reds without the two `SWITCHED_ROUTES` names. - `shell-screen-route-census.test.ts` `walks the route tree and finds them` reds without the two switch names. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) Two entries in `MOBILE_WEB_PAGE_ROUTES`, five grants each, with the reason for each grant read off the screen that needs it. The two lists are equal on purpose: the hub's changed-file rows push review and review replaces back, and a target declaring no more than its opener is a hop the handoff keeps inside the document. Registering either alone would have put a native frame and a second bridge session between a changed-file row and its diff. `pageRouteGrants` is derived from this list, so the two rows are a consequence of the entries and there is no second table to edit. `pr` and `history` stay native redirects and are never listed; the derived target list at this tree is [files, files/preview, source-control, [p], accounts, agent-history, review, session, tasks, web], with no `pr` or `history` row, because the census reads call sites and both redirects name `source-control`. Measured on this tree, not carried from the draft: - The hop census goes 8 -> 16. The eight new rows are exactly `{/h/[hostId], agent-history, files/[worktreeId], files/preview} -> {source-control, review}`, each handed off for `native.clipboard.write` and the first four also for `externalLink`. `source-control <-> review` is absent in both directions, which a new case now asserts as grant-list equality rather than as the absence of a row — absent is also what an unregistered route looks like. - The Back census now walks six trees and finds 8 controls, both rules printing empty. The two new ones are `MobileSourceControlHeader.tsx:46 role=button label=Back to session` and `MobileDiffReviewHeader.tsx:48 role=button label=Back`, which is what C4.3 bought. The `ARRIVING_SCREENS` describe it wrote for this moment is removed: with the rows in `PAGE_SERVED_SCREENS` its trees are covered and its cases were a second reading of the same thing. - Both closures reach the haptics seam, so `haptics` is declared by measurement: the seam census derives the reaching set and its two cases pass with the routes in its `ROUTE_MODULES` map. Without the two manifest entries these red on this tree: `pins every hop the handoff must take away from the page`, `keeps the hub and review local to each other`, `declares only routes the bundle has a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`, and both haptics-seam cases. `build-mobile-web-app-bundle.test.mjs` is split rather than fenced. The two pinned entries put it at 607 non-comment lines against the 600 cap, and the declaration block is a different concern from how the bundle is built — it grows once per registered domain while that file does not. It moves whole into `mobile-web-page-routes.test.mjs`, named for the module it is written against, so the next route to register does not have to choose between a lint fence and a split it did not ask for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render-check the two page routes, and make the oversized stage-all readable (OTA phase C, C4.4) The render check mounts both routes in a real browser, asserts each paints its own screen rather than the Unmatched route with no console error and no page fault, and asserts each fetches its own chunk on a client-side navigation. It also reads the shipped `img-src 'self' data:` out of the Kotlin source it is served with, pins the Swift twin beside it, and asserts neither route leaves the origin or logs a policy violation while it paints. The avatar skip itself (ruling 3) is `PRCommentCard`: on web it renders its existing empty-avatar `View` rather than letting one `<Image>` per comment attempt a fetch the policy refuses. Its branch is pinned by a component test, which reds on the platform check being removed. The render check's off-origin case is honest about being the negative half only — no comment card renders there, because the PR chain behind it is not scripted, and the file says so. The `useAnimatedScrollHandler` risk is answered by the two static facts rather than by a probe, and they are recorded as assertions: the hook is deliberately outside the four `MAPPER_HOOKS` because it is an event handler, and its updater's only effect is a write to `scrollOffsetY`, which `RightDrawer.tsx` assigns in two places and reads in none. A later read reds that case the moment it is added. The `oversized` stage-all refusal (ruling 2, made testable by ruling 5) was a silent no-op, and this is the fix as well as the case. Measured on this tree before it: `git.bulkStage` with 12,000 paths posts one 1,033,012-byte frame, the shell's reader drops it with `{ kind: 'refused', refusal: 'oversized' }`, and the page's promise never settles — `busyAction` never cleared and `setActionError` was never called. Both new cases red by timing out at 15s against that path. Refused at the page's own send boundary instead, under the shell reader's own predicate rather than a second spelling of it: `isBridgeFrameWithinCap` is extracted from `parseBridgeMessage` and used by both sides. `sendFrame` answers `sent` / `oversized` / `port-failed`, so `sendRequest` rejects with a `BridgeRequestOversizedError` whose message is a sentence the panel puts on its error surface, and the members whose contract is a boolean keep it. No delivery-unknown mark: the frame never left, so nothing ran on the desktop and the smaller retry is safe to offer. The case runs the real chain — bridge port pair, `useMobileGitRequests`, `runGitWorkflow` — with only react-native and the haptics seam mocked, and asserts the message that lands is a sentence and that the busy flag is raised and then cleared. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the type assertions from the two new C4.4 test files (OTA phase C, C4.4) The changed-code quality gate named five, all in the files the previous commit added, and a fence is not the answer to any of them. A separate commit because a reported head does not move by amend. - The comment fixture is a real `PRComment` rather than a cast: the type's six required fields are all this case needs, and the SAFETY disable that stood in for them was inert anyway — oxfmt had wrapped it onto three lines, and a wrapped `oxlint-disable-next-line` matches nothing. - The image lookup goes through `findAll` on the host tag rather than `findAllByType`, which takes a component. Through `String`, because `node.type` is `ElementType` and React Native declares no intrinsic elements, so the compiler reads a bare tag comparison as unreachable. - The runners hook takes its router from `useRouteHandoff` with expo-router mocked under it, which is how a `RouteHandoff` is obtained rather than asserted into existence. No target is pressed. - The rejection and the diagnostic are read through narrowings instead of casts, which also drops an `expect.any` that only type-checked because of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a frame the page cannot serialize inside the send contract (OTA phase C, C4.4 round 1) Round 1 finding 1. The oversized refusal moved `JSON.stringify` outside `sendFrame`'s `try`, so a frame carrying a cycle, a `BigInt` or a throwing `toJSON` threw past the whole send path. Three things followed, all measured here on a cyclic `params`: - the caller was rejected with a bare `TypeError` from `JSON.stringify` instead of the `BridgeSendFailedError` every other undelivered frame raises; - no `send-failed` diagnostic was raised, so nothing recorded that a frame had been lost; - `sendRequest` opens the id before it posts and abandons it on the way out, and the throw skipped the abandon: 63 of the 64 in-flight slots were usable afterwards, against 64 on a client that sent no such frame. Sixty-four of them and every later request is refused with nothing to say why. `posted()` carried the same escape into the members whose contract is a boolean, where a throw is worse still: those callers are taps and teardowns with no catch on them. Serialization goes back inside the `try`, with the oversized refusal kept in front of the post. The docstring said the port arm's throw is never `JSON.stringify`'s, which was exactly the assumption that broke; it now says why the call sits where it does. The new file is the pin: the rejection's name, the diagnostic, nothing reaching the shell, and the slot count with a no-cyclic-frame control beside it so the count cannot pass by the cap moving. Both changed cases red on the serialization moving back out — `expected 'TypeError' to be 'BridgeSendFailedError'` and `expected 63 to be 64`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the page's frame cap to the reader's, at the boundary and by construction (OTA phase C, C4.4 round 1) Round 1 finding 2. Nothing held the sender's predicate to the reader's. Replacing `isBridgeFrameWithinCap(json)` with an inline `json.length > BRIDGE_MAX_MESSAGE_BYTES + 1` passed 85 of the 86 mobile-web-shell and source-control test files on this tree, and a frame at exactly cap+1 would then be posted and silently dropped — the hang the refusal exists to end, back for every frame in that one-unit band. Two rules, because either alone passes against the defect: - The boundary. A frame of exactly the cap is posted, arrives at `parseBridgeMessage` and is accepted; a frame one byte over is refused with `BridgeRequestOversizedError`, posts nothing, and is the same string the reader answers `oversized` to. An off-by-one reds the second. - The census. The client reaches the cap through the shared predicate and does not name `BRIDGE_MAX_MESSAGE_BYTES` at all, and the module that exports the predicate is the module that parses inbound frames. A private copy that is correct on the day it is written reds here. The overhead the boundary frames are built from is itself checked rather than trusted: a frame asked for at exactly the cap must serialize to exactly the cap, so the constant cannot rot behind an envelope that grew a field. Against the mutation both new rules red — `expected null to be 'BridgeRequestOversizedError'` and the census failing to find the predicate — while the rest of the suite stays green, which is the finding reproduced. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): count the outbound frame in the unit both shells count it in (OTA phase C, C4.4 round 1) Round 1 finding 3. Read off both shells rather than assumed, and they agree: iOS gates the inbound frame on `json.utf8.count` (`MobileWebShellView.swift`, through `MobileWebShellBridge.acceptsByteCount`) and Android on `json.toByteArray(Charsets.UTF_8).size` (`MobileWebShellView.kt`, through `acceptsMobileWebShellBridgeByteCount`), both against `640 * 1024`. UTF-8 bytes on each platform. The predicate was already right. `isBridgeFrameWithinCap` decides on `utf8ByteLength`, and the `raw.length` clause in front of it is a cheap refusal in the safe direction, not a second rule: every code unit encodes to at least one byte, so a string over the cap in units is over it in bytes too. The diagnostic was not. It reported `json.length` — UTF-16 code units — in a field named `bytes`, so a frame of CJK text read as a quarter of the cap at the moment it was refused by it. It now reports `utf8ByteLength(json)`, and the type says which unit that is. Pinned with a 250,000-character frame of three-byte characters, which is under the cap in code units and over it in bytes, plus a source case reading the measuring expression out of each shell. Three mutations, all red: dropping the byte clause from the predicate reds the refusal (`expected null to be 'BridgeRequestOversizedError'`) and the diagnostic; reporting `json.length` again reds the diagnostic alone (`expected 250094 to be greater than 655360`), which is the defect this commit fixes, in the number it would have printed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): drop the render check's avatar assertion, which could not fail (OTA phase C, C4.4 round 1) Round 1 finding 4. The case asserted that no avatar host was requested while both routes painted, which reads as a proof of the web skip and is not one: no comment card renders on either page, because the PR chain the file's own closing note names is not scripted. Reproduced here — deleting the `Platform.OS !== 'web'` guard from `PRCommentCard` leaves the file at 5 passed. Deleted rather than propped up. Giving the page a presence precondition means five hand-written fixtures against five Zod schemas inside the shell double, which is exactly what the harness's docstring says that double must not become. So the only proof of that branch is `pr-comment-card-web-avatar.test.tsx`, which reds when the check is removed, and the render check now says so in its header instead of implying otherwise. What survives is a property of these two closures rather than of that component: not one request leaves the origin while either route paints, and nothing either paints violates the policy. That one can fail — planting a `fetch` to a provider host in a module both routes reach reds it twice, on the console-error case and on the off-origin case, with the `connect-src 'self'` refusal in the output. The CSP half is unchanged and was never in question: the served header is read from the Kotlin source and the Swift twin is pinned beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
8297e1d388 |
chore(mobile): stop ignoring the retired document-factory artifact (#21962)
* chore(mobile): stop ignoring the retired document-factory artifact C7.5c (#21878) retired the generator that emitted src/terminal/terminal-webview-document-factory.generated.ts, but the .gitignore line stayed. In any tree that ever built it the orphan stays invisible to `git status`, and terminal-webview-consumer-census.test.ts reads it as source and reds by name. Unignored, it shows as untracked and the ordinary stray-artifact cleanup removes it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): drop the retired document-factory artifact from oxlint's ignore list pullfrog on #21962: the same retired path was still listed under ignorePatterns in mobile/.oxlintrc.json. Inert (the file no longer exists), removed for the same reason as the .gitignore line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e6de7a4bbe |
refactor(mobile): thread the scope through the terminal document and bundle the native script (OTA phase C, C7.5c) (#21878)
* 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 * 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 * 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 * 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 |
||
|
|
8d42410e01 |
feat(mobile): render the HTML preview on the page in a sealed srcdoc frame (OTA phase C, C7.10 A) (#21862)
* feat(mobile): offer a cancelled top-frame navigation to the shell's opener Both shells cancelled every navigation off their own document in silence: iOS `decidePolicyFor` allowed only `isMainFrame && isDocumentUrl`, Android's `shouldOverrideUrlLoading` dropped anything whose resolved path was not "/". Nothing opened. That is the whole of ruling 29's "if they do not": a user tapping a link inside C7.10's sealed HTML-preview frame reaches the top frame as a navigation request, and the shell was the only thing that could act on it. A cancelled main-frame navigation now reaches JS as `onExternalNavigation` and goes through the same `Linking.openURL` the `externalLink` notify already uses. The scheme list is not restated natively: the native side caps the string and says which frame it came from, and `readBridgeExternalLinkUrl` decides what opens in the half that ships over the air. A subframe navigation is never offered, because that is the sealed preview loading itself. swiftc check: OK (`checkCancelledNavigation` added, the whole suite runs). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render the HTML preview in a sealed srcdoc frame on the page C7.6 gave the page the artifact's source, which is the native component's Source tab and half its job (ruling 8). Ruling 26 makes that debt: the Preview tab comes back as an `<iframe sandbox srcdoc>` inside the page's own document. `srcdoc` rather than a `blob:` URL, and no CSP change at all. Measured on Chromium and WebKit: a `srcdoc` frame has no URL for `frame-src` to match and inherits its embedder's policy instead, so it is admitted under the shipped `frame-src 'none'`, while a `blob:` frame is refused by `frame-src` on both and refused a second time in WebKit by the `frame-ancestors 'none'` it inherits. Two independent fences seal it, and the render check measures each on its own: the sandbox grants neither `allow-scripts` nor `allow-same-origin`, and the inherited `script-src 'self'` refuses the artifact's inline script even when a control arm grants `allow-scripts`. The inherited `img-src` and `font-src 'none'` govern its subresources, against a no-header control where the same three are fetched. `allow-top-navigation-by-user-activation` is the one token granted (ruling 29), so a tapped link becomes one top-frame navigation the shell now opens externally, while a `<meta refresh>`, a form submit, `target="_blank"` and any script-initiated navigation produce none. `lucideBarrelPlugin` is exported from the bundle builder so the check builds the toolbar's icons the way the page does rather than carrying a second shim. config/scripts suite, this file: 14 passed, 0 errors, exit 0. Control runs: a literal `sandbox` in the JSX reds 4, an added `allow-scripts` reds the script fence and the token census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's sealed frame where the degradation was pinned The three HTML-preview cases in this file described the state ruling 26 retires: no toggle, no frame, the source only. They now pin the frame's shape through the test renderer -- the artifact reaches it as `srcDoc`, the sandbox grants neither `allow-scripts` nor `allow-same-origin`, both toggle positions exist, and Source takes the frame away with it -- and the "never renders the html itself" case becomes "never puts it anywhere but the frame", counted rather than merely absent. What a browser does with that frame stays in the render check, which is the only thing that can answer it. The rich Markdown editor's half is unchanged: it is still the plain field, and item C is a later PR. Two mocks added: `Pressable`/`ScrollView` on the react-native double, because the toggle renders one, and `lucide-react-native`, whose barrel imports a `LucideProvider` its own context module does not export and so does not load under vitest at all. 9 passed, exit 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a link-activated top-frame navigation, even to the document F1, blocking, with F5 and F6 folded in because they are the same decision and splitting them would mean three rewrites of one function. F1: `<a href="/" target="_top">` and `href=""` in an artifact resolve against the embedder's base, so both named the shell's own document URL -- which both shells ALLOWED (iOS `isDocumentUrl`, Android's path `/`). One tap inside the sealed preview reloaded the shell's page: bridge target cleared, load state restarted, page state gone. A navigation a human started is now never allowed, whatever it names; it is offered instead, and `cancelledShellNavigationTarget` drops `orca-mobile-web:` in silence exactly as it drops `/h/other`. The page rewriting its own path carries no gesture and is still allowed. F5: the OFFER is gated on the same gesture, so a top-page meta refresh or a redirect is cancelled and never opened externally. F6: iOS returned early on `shouldPerformDownload` before the offer, so `<a download>` was dead on iOS and opened on Android. The early return goes; a download is refused rather than allowed when nothing started it, and a gesture-started one reaches the opener on both platforms. The allow half and the offer half are now one function per platform (`MobileWebShellNavigationPolicy.verdict`, `mobileWebShellNavigationVerdict`), so they cannot drift. The gesture is the platform's own answer: `.linkActivated` on iOS, `request.hasGesture()` on Android. Native tests, both platforms: document URL + gesture refused and offered; document URL without gesture allowed; foreign + gesture cancelled and offered; foreign without gesture cancelled and silent; download both ways; subframe never offered. swiftc OK; control run with the gesture rule removed exits 133. Gradle MobileWebShellDroppedNavigationTest tests=8 failures=0 errors=0. Also corrected: the screen comment that claimed the document's own reloads reach the handler (they never do), and the prop doc, which now states the gesture rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count own-origin top-frame navigations, and drop the goto cap F2: `page.setDefaultTimeout(4000)` capped `page.goto` at 4 s while every sibling render check uses the 30 s default, so under load the first WebKit cases redded on the navigation rather than on anything they assert. The cap goes; the per-action timeouts that needed to be short are already passed at their call sites. F1's page-side half: the rig now routes the page's own origin as well as the foreign one and counts main-frame navigations to each separately, with two cases pinning that `href="/"` and `href=""` each produce exactly one own-origin top-frame request. Playwright is not the shell, so what these state is the request the shell is handed; refusing it is the native tests' job and the docstring names which ones. The own-origin route is registered after the initial load, because it aborts main-frame navigations and the first `goto` is one. The foreign-tap and meta-refresh cases now also assert zero own-origin navigations, so a fix that merely moved the target would not pass. 16 passed, exit 0, no Errors line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait for the preview frame's own load, never a clock CI read the child frame before its srcdoc committed: frameUrl came back '' and the control arm's script as not yet run. The frame list, the frame's URL and anything read inside it settle at their own moments, and a 900 ms wait reads whichever of them has happened -- on a loaded runner, none. Polls for a child frame at about:srcdoc with its load fired, bounded by the case's own timeout, and an override arm now resolves on the document its srcdoc assignment commits rather than on the assignment. Red-first: with a 2.5 s mount delay standing in for a loaded runner, the paint case failed on both engines before this and all 16 cases pass after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say whose violations the preview rig reads The list is the main frame's: securitypolicyviolation does not cross into a frame, so an empty one says the embedder raised none and says nothing about the artifact's own style, image or font. A listener inside the frame cannot be the fix -- the fence under test is that nothing in the artifact runs. So the comment now claims what the reading supports, and names where the frame's containment is actually measured: the pixel for its inline style, the counting server for its img-src and font-src. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): announce which side of the preview toggle is showing The Preview/Source pair carried a label each and nothing else, so which one was showing lived only in the active background -- invisible to a screen reader on both surfaces. Each button is now a tab carrying its selected state, inside a tablist, and the two files' toolbars stay character-identical so the page and the phone announce the same thing. Red-first: the new case renders both siblings and failed on both for the missing role before this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the WebView mock like the file's other hosts The anti-slop gate refuses a bare `object` parameter. Takes the same shape as the react-native mocks beside it, which pass it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): allow only the load the shell itself started The document URL was allowed whenever the host reported no gesture, so a navigation the shell never asked for could reload the page out from under the session. Measured against a real WKWebView off-device: a sandboxed subframe navigating the top frame to the document URL arrives as `.other` with no gesture at all, and Chromium's own docs allow hasGesture() to be false for a request a human started. Census first: nothing in the page navigates the top frame -- no location assignment, reload, replace, window.open or form -- the router moves by pushState and replaceState only, so the rule needs no gesture and no page cooperation. Both shells now raise a flag around their own load and drop it at commit, and allow a main-frame navigation only while it is up. Everything else naming the document is refused and never offered, since offering it would send the user out of the app. iOS carries the second discriminator the same probe measured: sourceFrame is the main frame for the shell's own load and the subframe for a subframe's top navigation, so a subframe can never take the allow path. Red-first: the Swift checks and the Kotlin tests were written first and failed to compile against the old signature. 9 Kotlin tests, 54 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): point the meta-refresh arm at the embedder's own URL The fixture pointed off-origin, so its own-origin assertion could not move whatever the frame did. The new arm refreshes to `/`, which resolves against the embedder's base, and pins zero top-frame requests on a counter the `href="/"` case proves reads 1 in the same rig. It also counts what the frame asks for itself, with a presence control that attributes the fence: with `allow-same-origin` and no policy the same fixture navigates the frame to the embedder's `/`, and with the policy dropped but the product's token kept it navigates nothing, so the opaque origin is what refuses it rather than the CSP. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read what an action produced, not what a clock allowed The 600 ms after every action is gone. An arm that expects a navigation now returns the moment the route handler records it, with a deadline only so a click that missed its target says so instead of spending the case's timeout. An arm that expects none waits for two painted frames inside the page and one 200 ms drain for the popup queue, which is a browser-process event with no in-page counterpart; the docstring says why that one is bounded. Measured and reported rather than claimed: with the new wait replaced by a no-op every arm still passes, because the reads that follow are each a round trip. It is insurance against the runner load that produced the frame-commit race, not a fix for a failure seen here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the settling branch as a ternary What oxlint's prefer-ternary asks for, and the changed-code gate with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the preview frame by its element, not its URL CI timed out on all seven preview cases on one engine: the poll waited for a child frame whose URL reads about:srcdoc, and that browser reports an empty URL for a srcdoc frame, so every case ran to its own timeout. The same difference had already shown as `expected '' to be 'about:srcdoc'`. The frame is now the element: waitForSelector('iframe') then contentFrame(), with readiness taken from the fixture's own marker inside it. Nothing compares a frame URL any more -- the paint case reads the element's srcdoc attribute and the absence of src instead, which is what "parsed inside the frame rather than fetched into it" actually means. The one arm whose artifact navigates the frame away says so rather than waiting for a marker that is not coming. Red-first: with the old poll keyed on a URL the browser never reports, both engines time out exactly as CI did; the new wait passes 18/18 with the 2.5 s mount delay still injected. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make a frame that never becomes ready say what it saw The runner's Chrome read the preview frame's URL as empty where three chromium builds here read about:srcdoc: bundled headless, the headless shell, and --headless=old, all 147. So the difference is not reproducible locally and the next CI run has to carry its own diagnosis. The marker wait is bounded well inside the case timeout, and on expiry it reports the frame's URL, the srcdoc attribute's length and the page's CSP violation list -- which separates a frame the policy refused from one that was merely slow, the two readings that look identical from a timeout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): run the containment arms the comment only claimed The comment said the fixture navigates nothing with the policy dropped and the product's token kept, but no arm ran it: the control dropped both fences at once. Both single-fence arms exist now, either of which would hold. Measured rather than assumed, and one of them is not what the comment said. The token alone: the navigation never starts, no request, no violation. The policy alone, with allow-same-origin granted: the navigation does start and frame-src refuses it, which the embedder reports as its own violation. The engines differ only in what is left in the frame -- chromium an error page, WebKit the artifact -- so neither is asserted; what is asserted is that the request never reaches the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a download that names the shell's own document The document branch skipped downloads, so `<a href="/" download>` fell through to the offer path carrying the shell's own URL. Harmless in practice, because the opener's scheme list drops it, but it contradicted the policy's own comment and the prop doc, and it left the one URL that must never be offered reaching the boundary. The branch now covers a download too: refused, from either frame, gesture or not, and never offered. A gesture-started download of anything else still reaches the opener. Red-first on both platforms: the Swift checks exited 133 and the Kotlin row failed against the old policy. 10 navigation tests, 55 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the own-load flag wherever a document ends The flag lived beside the load call and had to remember every ending separately, so iOS missed two: a prop update that fails before it loads, and a renderer that died. Both left it raised, and a navigation to the document URL during that window would have been allowed. It now lives in the load state machine, which every ending already runs through -- a commit, a failure, a dead renderer, a prop update, a reset -- on both platforms, so there is nothing left to remember. The view raises it and reads it, and drops it nowhere. The Android residual is stated in the policy rather than papered over: between loadUrl raising the flag and onPageStarted dropping it, a navigation to the document URL from inside the preview frame would be allowed, because that callback says nothing about which frame asked and no host discriminator exists. It needs a generation switch and a tap in that window; iOS closes the same gap with sourceFrame. Red-first: the new Swift row failed to compile and the Kotlin row with it. 12 load-state tests, 56 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): spend the own-load flag on the allow, not on the commit The flag stayed raised from the load until didCommit, so a second main-frame action naming the document inside that window was allowed too and replaced the document. WebKit can decide a second action before the first one starts, so the commit is too late to be what spends it. The allow itself spends it now, before the decision goes back, and every ending still drops it for a load that is allowed and never commits. Red-first: the new check composes the machine with the policy -- the seam the flag and the rule meet at -- and failed to compile against the old machine. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop raising an own-load flag Android never consults WebViewClient's javadoc, verbatim: "This callback is not called for all page navigations. In particular, this is not called for navigations which the app initiated with loadUrl(): this callback would not serve a purpose in this case, because the app already knows about the navigation." So the flag guarded nothing on this platform and, while raised, was the one thing that could have let a competing request to the document URL through. The view passes isShellLoad = false always now, the machine drops the field it had no raiser for, and the policy comment carries the quote. Nothing reaching that callback is the shell's own load, so nothing naming the document is allowed there at all -- which also closes the generation-switch window the residual named, so that paragraph goes. No red to show: this is a removal, and the behaviour it leaves is the refusal the existing rows already pin. What a device proof must check is stated in the policy instead: a WebView that did route its own load here would have it refused and the load state would sit at loading. 55 tests in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): settle every arm, not only the ones that tap An arm with no action read its counters as soon as the frame's marker appeared, so a zero-delay meta refresh could dispatch after the reading. The arms that pin zero were the ones relying on it. Every arm settles now, and what it settles on is what it expects: the sealed refresh arms take the bounded no-navigation path, and the loose arm waits for a recorded navigation that is neither main-frame nor foreign -- its own frame's -- rather than the main-frame wait it would never satisfy. Red-first: with the settling removed and the refresh moved to 2 s, the loose arm reads 0 on both engines; with it back, 1 on both, the delay still in. A 0.4 s refresh passes either way, which is why the finding was invisible. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait for what the artifact's script wrote, not for the element The two-fences control asserts the inline script ran, and the marker element it waited for exists from parse time, so the arm could read window.__ran before the script had touched it. Under a loaded runner that reads 0, which is CI's "expected +0 to be 1" on chromium. Readiness is now per-arm: 'script' waits for the script's own write, 'load' for the arm whose artifact navigates the frame away, 'artifact' for the rest. Red-first: with the inline script's write delayed 1.5 s, the old arm fails on both engines with that exact message and the new one passes, delay still in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): bound the rig's waits by the case timeout and nothing else Two inner deadlines, 20 s and 15 s, were racing the outer one they sit inside, so a slow runner could fail a case on a number this file picked rather than on the one the case declares. Both now run to vitest's own `ctx.signal`, which aborts when the case times out. On abort the rig prints its reading -- the frame's URL, the srcdoc length, the violation list, or the navigations it did record -- and lets the case fail as the timeout it is. Nothing is rethrown from that path: a rejection raised after vitest has given up on a case has nobody left to catch it, and an unhandled one fails a run whose every test passed. Red-first: with the marker selector pointed at an element that never appears and the case timeout cut to 8 s, the diagnostic prints and the case fails as `Test timed out in 8000ms` rather than hanging in silence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): ask a stuck preview frame everything it can still answer The old diagnostic said only that a frame never parsed, and its violation list was the top document's -- securitypolicyviolation does not cross frames, so it said nothing about what the frame itself refused. It now prints the browser version, the arm it came from, the iframe element's srcdoc length and sandbox, contentDocument.readyState and contentWindow.href (which answer for a same-origin arm and report `refused` for an opaque one, so the arm's own origin is in the log), and every Playwright frame with its url, name, readyState, body length, marker presence, window.__ran and its own violations. Per frame, because the page's init script installs the collector in every frame -- measured on both engines -- and CDP evaluates inside an opaque frame whose scripts are blocked. Two corrections that the local probes forced. The reading is sampled while waiting and printed from the last sample: read at the abort it lost its race with vitest's teardown and printed nothing at all. And two arms had never been given the case's signal, so their waits could not be bounded or diagnosed. The diagnosis moves to its own module because the test file is at its line limit, and because the bound and the reading it prints are one thing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build a widened control frame instead of relaxing a live one A live frame cannot be relaxed. Sandbox flags are fixed on a browsing context when it is created, and Chrome 152 keeps the original ones through a srcdoc reassignment while still parsing the new document -- so the control arms that widened the product's own frame stayed sealed on the runner, and CI read a script that never ran and a refresh that never navigated. Chromium 147 here honours the relaxation, which is why it passed locally for a year of runs. The override now clones the element, sets the sandbox on the clone, gives it the artifact and replaces the product's frame with it, so the widened flags are there from creation -- the way the product does it, since React sets the attribute before insertion and never after. The product's own arms are untouched: a null override still returns immediately. And the control can no longer pass for the wrong reason on any engine. The header-keeping arm now reads the violation raised inside the frame: a script-src refusal can only happen if the sandbox let the script start, so it separates "the policy held" from "the frame was never widened", which the old arm could not. The loose arm pins an empty list beside it, the sealed arm pins an empty one too, and those three readings are the whole fence story. The violations come from each frame's own collector, because the embedder never sees them. Two diagnostic repairs the local probes forced: the browser version is read once at open, since asking at the abort printed "browser unknown" in the CI log this exists for, and the reading is sampled immediately as well as every five seconds, since a wait that only prints "no reading was taken" says nothing. Red-first: with the widening disabled, both engines fail exactly as CI did -- 180 s timeouts on the script arm -- and the diagnostic names the arm, the version and the sandbox it actually had. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put the toggle's selected state where a browser reads it CodeRabbit is right, and the browser says so: react-native-web's createDOMProps never reads accessibilityState, so on the page the tab pair emitted role="tab" and no aria-selected at all. The test renderer could not see it, because it reports the props the component was handed rather than the DOM they become. Both siblings carry aria-selected beside accessibilityState now -- the phone's screen reader takes the latter, the browser the former -- and the toolbars stay character-identical. Red-first, in a real browser on both engines: the rig now reads every [role="tab"] element's aria-selected before and after the tap, and it read null for both positions before this line existed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
4a3a32206d |
refactor(mobile): the terminal document is a function of its host (OTA phase C, C7.5b) (#21859)
* 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 * 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 * 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 * 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 |
||
|
|
5d13a70ea3 |
fix(mobile): keep an in-page hop local only when the session's grants cover it (OTA phase C, C2.9) (#21723)
* feat(mobile): carry what each page route declared in init (OTA phase C, C2.9) The page decides an in-page hop from `init.pageRoutes`, which says which patterns this shell would render and nothing about what each one costs. So a push kept local on the strength of the pattern alone runs the target under the opener's grants — which is how the tasks page is reached from the wide-layout sidebar without `native.clipboard.write`, and why its copy actions refuse silently. `init` now also carries `pageRouteGrants`, the manifest's own route/grant pairs, from the manifest the shell already holds. Optional in both directions: an older shell omits it and an older page ignores it, and a page that receives none keeps today's rule. No new frame kind, no cap change, no protocol bump. The grammar is the manifest's, imported rather than restated (`MobileWebBundleGrantNameSchema`, now exported for this), so a grant name the bundle could not have declared cannot reach the page through this field either. The host validates the pairs before it builds the frame and refuses the session when they fail, for the reason it already refuses a malformed route: an `init` the page would reject whole is worse than no session at all. Two files were at their line ceiling and are split rather than bumped. The pairs schema moves to `bridge-page-route-grants.ts`, which is read by both the envelope and the host, so it belonged in one place anyway. In the session reducer the three sites that each spelled out "patterns, their grants, this route's grants" become one `routeViewOf`; that is a net reduction and removes the fourth spelling before it is written. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a hop local only when the session's grants cover it The rule the page was using is "the shell would render this pattern", and that is not the question. Grants are resolved once, from the route the shell opened, so a push kept local runs the target under the opener's list. On a wide layout the sidebar renders beside every `/h` route and pushes `/h/<id>/tasks` through this seam, so from the worktree list, agent history or the files pages the tasks page ran without `native.clipboard.write` and its copy actions refused with nothing on screen to say why. `servedHere` now means served here *and* covered: the target's declared grants must be a subset of this session's. An uncovered page route is handed to the shell exactly like a non-page route, and the shell opens it as its own session with its own grants — which is the mechanism that already exists, rather than a new one. Three answers, not two, because an absent field is not an empty one. A shell that sent no pairs keeps the old behaviour: `null` is "nobody told me", and an older shell has to keep working. A target the shell lists but names no entry for is *not* covered — the page cannot justify that hop, so it hands it over rather than guessing in the direction that loses grants. This is C3.1's explorer ⊇ preview finding without its pairwise pin: that hop is covered by this rule and stays local, and the rule scales to the sidebar, which reaches every route and which no pairwise list can keep up with. Red first on the two cases only the new rule answers; the other four are the regression guards and passed before and after. Two whole-session assertions gained `pageRouteGrants: null`, which is what the reader now returns. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the sidebar hop in a browser, under the session's own grants The unit tests pin the decision; only a browser shows the control exists, is reachable at the viewport where the sidebar renders, and that the document does not move when the hop is handed over. Four cases on the shared harness, which now forwards `pageRouteGrants` (omitted when a caller names none, because an absent field is not an empty one and the page reads the difference). - Wide, session without `native.clipboard.write`: tapping Tasks posts exactly one `navigate` notify, the document stays on the worktree list, and **no new chunk is fetched** — which is what says the page did not quietly render tasks under the wrong grants. - Wide, same tap with the grant added: no notify, the document moves to `/tasks`. Without this the first case would pass on a page that simply never navigates. - Wide, shell sending no pairs at all: the old behaviour, local. An older shell must not start handing every hop over on a field nobody sent. - Narrow: asserts the absence rather than a tap. `app/h/_layout.tsx` renders the sidebar only on a wide layout, and only that header branch labels its Accounts and Tasks controls; the narrow header's are unlabelled pressables. So the hop does not exist at that viewport, and `getByLabel('Tasks')` finding nothing is the honest assertion. That unlabelled narrow header is a real accessibility gap and is not this lane's to fix. Registered in `pr.yml`'s `mobile_web_app` job beside the other render checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the in-page hops a session's grants cannot cover The rule landed in the commit before this one decides each hop; this says which hops those are, so a route's grants growing — or a new push between two page routes — shows up here rather than as a verb that silently refuses on a device. Openers are every page route, not the one that happens to push. On a wide layout `app/h/_layout.tsx` renders the worktree-list sidebar beside every `/h` route and its header pushes tasks, which is exactly why a pairwise pin is the wrong shape: the sidebar reaches everything, so the census has to be the cross product of what the manifest declares against what the source actually builds. Targets come from the hrefs the app builds, read out of `mobile/src` and `mobile/app` and reduced to route patterns, so a hop nobody writes is not pinned and a hop someone adds is. A presence case asserts the sidebar's tasks push is among them, because a census that stopped finding hops would go quietly green. Two hops are pinned as handed off today, both into tasks, which is the only route declaring more than `navigate` and `storage`. A third case asserts the other half of the rule on the manifest: a target asking for no more than its opener stays in the document. Checked that it discriminates rather than assuming: widening the worktree list's grants to cover tasks fails the pin, and restoring them passes it. **No pin was deleted.** The brief expected C3.1's pairwise explorer/preview pin to be replaced here, but C3.1 is not on this base — `MOBILE_WEB_PAGE_ROUTES` has three routes and no `files` entry, so there is nothing to remove. When C3.1 lands, its pin is this census's to subsume. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an unused import from the hop census `statSync` was imported and never used; `oxlint` fails it. My error: I committed the census on a green test run without waiting for lint, the same order mistake I made earlier in this lane. Fixed forward rather than amended, because the lane forbids rewriting a commit that exists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fold C3.1's pairwise grant pin into the hop census C3.1 landed while this branch was open, and it brought the case this lane generalises: the explorer pushes to its own preview, that push stays in the document, so the preview runs under the explorer's grants. Its pin asserted that one pair by name. The census now covers it as a consequence rather than a rule. With the files routes in the manifest the cross product finds six more hops the session cannot cover — the sidebar into files from the worktree list and from agent history, and both files routes into tasks — and it does **not** find explorer → preview, because the preview declares no more than the explorer. That absence is the pairwise pin, derived. So the pairwise block is deleted, with its import. The rest of that file stays: its external-link seam checks and its clipboard-absence control are about what the files closure contains, which this census says nothing about. Checked the extended census still discriminates: granting the explorer `native.clipboard.write` fails the pin, restoring it passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the sidebar hop from a files route, not only the worktree list The defect is not "the worktree list pushes tasks". On a wide layout the sidebar renders beside every `/h` route, so the same hop exists from the files explorer, whose session carries `externalLink` but not `native.clipboard.write`. One opener proving the rule would have left the general case to inference, which is the inference C3.1's pairwise pin already made once. Opened on `/h/<id>/files/<wt>` with the files route's own grants, the sidebar's Tasks control posts exactly one `navigate` notify, the document stays on the files route, and no new chunk is fetched. The harness helper now takes the route and the text to wait for, so a case can open on something other than the worktree list without a second copy of it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the render helper wait on the text its caller named The `awaitText` parameter I added in the commit before this one was never wired into the wait, so it was dead and `oxlint` failed it. The case still passed, because the files route renders the host name in its sidebar and that is what the helper was still waiting on — which is exactly the kind of accident a dead parameter hides. Third time in this lane I have committed on a green test run before lint finished. Fixed forward, not amended. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): carry route grants through the download path `onManifestRead`'s download branch set `pageRoutes` and `routeGrants` from the new manifest and dropped `pageRouteGrants`; nothing downstream recomputes it, so every first install and every OTA update reached `ready` with the default or the previous generation's pairs. The page then read each target as listed-with-no- entry and handed off every in-page hop. `routeViewOf` moves to `page-route-policy.ts`, beside the two functions it calls, to keep the reducer under its line cap without a bump; its stale neighbouring comment, which described a filter that moved into it, goes. Red first: the cold-cache and generation-change cases failed, the cached-hit case already passed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): derive census targets from navigation call sites The reachability filter was inert. Harvesting every `/h/${…}` template caught the five screens that declare their own mount pathname, two `pathname ===` comparisons and the route template types, so every declared route was reachable through its own mount: the pinned table was the all-pairs one, eight hops with the filter and eight without. Targets now come from the arguments of `router`/`navigation` `push`, `replace` and `navigate`, and of `navigateFromHostList`; mounts, comparisons and types are excluded by construction because they are not navigation arguments. Two real hops are not written as a literal, so a local binding or a call is followed one step to the function that returns the pathname: the files explorer is pushed as `{ pathname: descriptor.pathname }` and the preview as `push(createMobileFilePreviewHref(...))`. A call site whose target cannot be read is returned rather than dropped. Derived patterns go from 11 to 10; the pinned table stays at eight because all five page routes are genuinely pushed to. What changes is that the filter now discriminates: deleting the header's two tasks pushes reds the presence case and drops the four `-> tasks` rows from the pin, where the old derivation stayed green on the same deletion because `app/h/[hostId]/tasks.tsx` still declared the pathname. A push added at a real call site appears in the set. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): restore the preview-declares-something guard The pairwise pin this case replaced asserted the preview declares at least one grant before asserting the explorer covers them all; without it two empty lists satisfy the subset check and a route that lost its grants passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): describe the route list under the handoff rule Two passages described the world before this PR: the explorer's note said the census pins its pair with the preview, and a closing paragraph left the sidebar's tasks hop open for a later PR. This is that PR. Covering the preview now buys the in-document hop rather than making it correct, an uncovered target is handed to the shell and reopened under its own grants, and the census reads the explorer to preview relation off this list rather than pinning it by name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mirror the manifest's tasks grants in both fixtures CodeRabbit on #21723: both fixtures declared the tasks route as `navigate`, `storage`, `native.clipboard.write` while the manifest also declares `externalLink`, so no covered-session case ever required it. Both now mirror the manifest's four, and the covered sessions hold them. That alone does not make an `externalLink`-blind rule fail, since those sessions hold every grant either way, so the unit suite gains the case that does: a session holding the clipboard but not `externalLink` must still hand the hop off. Mutating the rule to treat `externalLink` as always held reds that one case and no other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make a stalled hop name its own cause Both waits for the hop to land read as a bare 30 s timeout when it does not. The CI failure that sent this file back was a `TypeError` inside React Navigation that blanked the document, and it was invisible here because the error assertions run after a wait that never returns. The wait now throws with the page's own account: the pathname it stayed on, the collected page and console errors, the `navigate` notifies posted, the first 300 characters of the body, and every `.js` response since the click with its status. The response listener records every script answer rather than only the 200s, so a chunk the navigation waits on can be seen failing; the 200-only list the no-new-chunk assertions read is unchanged, as is everything the five cases assert. Kept in this file because no other render file waits on the pathname moving. Proved by mutating the rule to hand every hop off: the covered case fails naming the pathname it stayed on, an empty error list, the notify it posted and no scripts since the click — which is the handoff signature, distinct from the crash signature CI saw. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): aim the narrow hop at the control C2.10 named The narrow case asserted the absence of a labelled Tasks control, which was true only because the narrow toolbar carried no accessibility props. C2.10 gave it the wide sibling's role and label, so the assertion was red on the merge and, worse, the rule this file is about went unproven on the branch the phone actually presses. It taps that control now: at 390 px there is exactly one, and the tap posts exactly one navigate notify for the tasks route while the document stays on the worktree list and fetches no new chunk. Red first against the merged header (count 1, expected 0); with the session given native.clipboard.write the hop goes local and the case reds, which is what says the assertions discriminate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop the handoff predicate's contradicted one-liner The pre-C2.9 summary said the answer is whether this document renders the target, which is exactly the claim the block comment below it replaced: the predicate now also requires the target's grants to be covered. Two doc comments on one declaration, the first of them wrong. Comment only; the 35 handoff cases are unchanged and green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert which field a route refusal blames The host builds `pageRouteGrants: <issue>` so a refusal says which of the two checked inputs failed, and nothing read it: the case counted refusals, so a host that reported the route's own verdict for a malformed pair would have stayed green while sending whoever reads the refusal to a pathname that was never the problem. The case pins the prefix, a non-empty issue behind it, and that the diagnostic and the callback carry the same string. The control is an opener that fails the other way: a malformed route reports its own issue and does not take this prefix, without which the pin would hold on any reason at all. Red first with the field branch dropped from the reason: the prefix assertion fails and the control stays green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop exporting the route filter the reducer stopped calling `implementedPageRouteEntries` and `implementedPageRoutes` were the reducer's two ways in before it moved to `routeViewOf`. The entries form had no caller anywhere afterwards and the patterns form had only this test, so the module's public surface advertised two functions no product code reaches. Both are module-local now; the surface is `matchesRoutePattern`, `pageRendersRoute`, `grantsForRoute`, `routeViewOf` and the grant list. The test reads the same list through `routeViewOf(...).pageRoutes`, which is the reducer's own view of it, so no assertion changed and no export is kept for a test. Red first: with both un-exported and the test untouched, seven cases fail with `implementedPageRoutes is not a function`; routed through the view all nineteen pass. Still discriminating, as a control: with the grant filter dropped from the entries helper, four of them fail. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): route the merged haptics cases through the policy view PR E's two haptics cases arrived with the merge calling `implementedPageRoutes`, which this branch had already made module-local, so the merged file was red with `implementedPageRoutes is not defined` on both of them. They read the same list through `pageRoutesOf`, the view the rest of the file already uses, so neither assertion changes. PR E's paragraph named that function for the filter it describes; the filter now sits in the entries helper the view is built on, so the sentence says that instead of naming a function the reader cannot see. Red: the two cases above on the merge. Green: all 21, PR E's two included. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mirror the haptics token in every handoff fixture PR E put `haptics` on all five manifest routes, and these fixtures still carried the pre-E grant lists: tasks with four grants where the manifest now declares five. A fixture that is short the same token on both sides of the subset check agrees with the rule by accident, and would have gone on agreeing after the token stopped being universal. The pairs mirror the manifest now, and each session carries what its opener route would actually be granted, since the host narrows a route's declared grants to what the shell implements and the shell implements the token. Red first, with the token added to the pairs alone: the two covered-hop cases flip to handed-off, `stays in this document when the session already covers the target` and `keeps the hop in the document when the session covers tasks`. Green once the sessions carry it, 35 and 5. The hop census needed nothing: it reads `MOBILE_WEB_PAGE_ROUTES` itself. Measured there, all 5 routes declare the token and it is the missing grant in 0 of the 8 uncovered pairs, so it cannot decide a hop and the rule still reads only `pageRouteGrants`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): count C2.9's two bridge modules in the session route closure #21908 recorded this pin at 4,324 for the haptics notify module. C2.9 adds two more that the same closure reaches: the page-route-grants schema and the manifest contract whose grant grammar it imports rather than restates, both pulled in by `bridge-envelope.ts`, which the page reads to parse `init`. Named in the docstring beside #21908's sentence rather than folded into its number, because the three modules arrived from two PRs and a single count with one reason invites the next author to assume the rest. Red first against 4,324: expected 4,326. Measured on this head, not inferred -- a control worktree at pristine main gives 4,324, so the two are this branch's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e9b180685b |
feat(mobile): render Mermaid diagrams on the page from one deferred engine artifact (OTA phase C, C7.10 B) (#21871)
* test(mobile): measure mermaid rendered in the page Red-first for C7.10 item B. The check mounts the real web sibling in chromium and webkit under the shipped shell CSP and asks four things of it: that a diagram renders with zero policy violations and zero eval / new Function calls, that the SVG is the native buildHtml's own output once the diagram id and xmlns:xlink are normalised away, that a hostile diagram lands inert, and that a source change, an unmount and a remount leave exactly one SVG and no listener of the first mount. The equality oracle is buildHtml itself, bundled for Node behind a Proxy stub for its native imports and served as its own document in the same browser, so neither side of the comparison is retyped. All eight cases fail on this commit: the sibling is still the labelled source box. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fence the session download rather than its module list Ruling 28. mobileWebAppRouteClosure reads metafile.inputs, which holds dynamically imported modules under splitting: true exactly as it does under splitting: false, so it cannot say "on demand" about anything: an on-demand mermaid moves the session route's module list 4320 -> 6362 while its download does not move at all. So the fence moves to entryStaticClosure. The new helper walks the emitted chunks from the output the route's own module landed in and follows import-statement edges only, and hands back both halves, because mermaid's absence from the download is only a measurement while its 66 files are present in the deferred half. The module list's new total is recorded in the docstring with its reason and asserted beside the engine's own file count, which moves only when the pinned mermaid version does. Red on this commit: no mermaid in the closure yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render mermaid in the page The web sibling stops being a source box. mermaid is a browser library, so the page imports it inside the render effect and draws the diagram in this document: no WebView, no 3.7 MB engine string, and nothing of the engine downloaded by a session with no diagram on it. What replaces the sandbox is mermaid's own securityLevel: 'strict', which runs its serialized SVG through DOMPurify. The native path's </script> escaping has no analogue here and needs none, because the source is a JS string argument rather than text spliced into an inline script. Measured in both engines: a script in a label, a </script>, an onerror and a javascript: click all land inert. The configuration is now one object both hosts read, so the theme cannot drift between the page and the phone; buildHtml serializes it instead of holding a second copy. It gains suppressErrorRendering, because mermaid otherwise draws its own error diagram into a temporary element and leaves that element behind when it rethrows -- an orphan SVG on the page, and on native a diagram the component is about to replace with the source box anyway. The dispose clears the host on unmount and on a source change; the id is a useId, because mermaid writes it into the stylesheet inside the SVG and it has to be a CSS identifier. Also re-records the closure total the previous commit pinned: with the real component the session route's module list is 6376, not the design probe's 6362, and the reason is in that file's docstring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): budget the deferred engine's chunks apart from the routes Putting mermaid on the page took the app bundle from 69 emitted scripts to 172, and the asset budget failed: 215 assets against a ceiling of 115. The cause is not a page split running away, which is what that ceiling is for -- it is that mermaid lazily imports each of its own diagram types, so one import() lands 103 scripts no route count predicts. So the ceiling gains a second term, named and measured (172 scripts with mermaid against 69 with it aliased to a stub, at 11.17.2), rather than the route term being raised to cover it. A page split running away still fails on the route term, and the failure still says which of the two grew. The consequence is worth reading twice: the derived ceiling has to stay inside the 256 assets the shell will load, and with 42 images it now crosses that at 24 routes instead of 50. The bundle is at 215 today with 14 routes, so there is room for about ten more routes before a green build produces a manifest no phone will open. Measured by the config/scripts suite failing on this head, not predicted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): pre-bundle the page's mermaid into one artifact import('mermaid') from inside the app bundle emitted 103 scripts, not one: mermaid lazily imports each of its own diagram types and esbuild splits along those boundaries. Every one of those scripts sits inside the OTA generation the phone has already downloaded, so the split moved no bytes over the wire and spent 103 of the 256 manifest assets the shell will load -- which is the scarce resource here, and the reason the previous commit had to invent a second ceiling term. So a sibling generator bundles the package into one ESM module beside the WebView engine it already builds, emitted by the same postinstall run, gitignored and lint-ignored with the others. The page imports that artifact on demand instead, through a loader whose return type names the two calls the component makes -- checked against the artifact's own inferred export rather than cast to it. Measured, at 14 routes: emitted scripts 172 -> 69 (68 with no deferred engine at all) manifest assets 215 -> 112 (111 with none) session modules 6376 -> 4323 (+3 over main: config, loader, artifact) chunks fetched for one graph TD 27 -> 1 bytes fetched 837,530 -> 3,482,965 The static-closure fence is unchanged in meaning and now reads on the artifact: absent from every chunk the route reaches by an import statement, present in the deferred half. The rendered SVG is byte-for- byte what it was, so the equality against the native document still holds on both engines. Also adds the diagram to the webview-consumers list, which is what that list means: its native component imports the package and its sibling is what the builder resolves instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * revert(mobile): drop the deferred-engine ceiling term, keep the control With the engine pre-bundled into one artifact the bundle emits 69 scripts at 14 routes against the route term's 72, so the second term this series added has nothing left to do and the route count is the only term again. mobileWebAppBundleMaxChunks and the asset ceiling derived from it are back to what main has; the shell's 256 assets are crossed at 50 routes again rather than at 24. What stays is why. A ceiling raised to admit 172 scripts would have admitted any split at all, so the budget test gains the control that holds the line: the single-artifact count passes the ceiling and the lazily-chunked count fails it, both measured at 14 routes, with mermaid named as what produced the second. Red before the term came out: the control failed asserting 172 > 175. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep build output out of the raw-request-port census The census walks mobile/src for AST reaches into the unvalidated request port, and the pre-bundled mermaid artifact is the first generated file under src that is executable code rather than a string literal. Two of its own vendored dependencies contain the token `sendRequest`, so the walk read minified third-party code as a new call site and asked for an inventory line nobody can ever migrate. So `*.generated.ts` joins node_modules and test files in that file's stated list of what it does not scan, with the reason. The scripts that emit those artifacts are ordinary source and are still scanned, which is where a real reach would be. Two halves to the new control, because a filter that skipped everything would satisfy either alone: nothing generated is left in the scan, and the matcher still finds the port when handed one line of code. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): escape the shared config into the native inline script buildHtml spliced JSON.stringify(MERMAID_DIAGRAM_CONFIG) straight into the inline <script>, twenty lines below the function that exists because JSON.stringify leaves `<`, `>`, `&` and the U+2028/9 separators raw. Inert at today's five hex colours, and not inert for a themeCSS or a font stack, which is free text going into the same script element. So the escaping splits from the stringify and both callers use it: the source keeps its own wrapper, the config gets one. Those characters only ever appear inside JSON string literals, so escaping them is valid for an object serialization exactly as it is for a string. Red first: a config carrying `</script><script>` put four raw closers in the document where a benign build has two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the page mermaid type against the package's own The loader returned the artifact's default as PageMermaid, which checked that two names exist and nothing about their shapes: the artifact is minified vendor output and both members infer as `any` there -- a probe assigning engine.render to a number compiles -- and `any` satisfies every signature there is. So the shapes are asserted against the package's `Mermaid`, which is precise. A PageMermaid member whose signature the engine does not really have now fails at this line rather than at a call the page makes. In the product module, not a test: mobile/tsconfig.json excludes test files, so a type-only assertion in one is never compiled. Underscored because it is a compile-time statement with no runtime reader, which is the form the linter asks for. Control, verified both ways: changing render to (id: number) => Promise<{ svg: number }> reds tsc naming both parameter and return, and the real signatures compile. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): re-measure the chunk series and say what it does not show The four-point series was stale and read as a slope it is not. Measured again on this head, by copying the route tree and dropping routes from the end of the sorted key list -- both siblings of each, because deleting a .web.tsx alone leaves the native file for the builder to resolve and measures an entirely different closure, which is how the first attempt at this produced 77 scripts for 14 routes: 8 routes -> 32 scripts 10 routes -> 43 12 routes -> 61 14 routes -> 69 (the real tree) Between four and nine more per route depending on which route, so 4r + 16 is a bound and not a fit, and the justification now says that instead of claiming three per route. It also says the part that matters more: at 14 routes the tree measures 69 against 72, and the last two routes cost the 8 the ceiling grants for two. The fence is at break-even, and the new assertion states that slope from the function rather than from a comment. Also records what the generation weighs, since every chunk ships in it whether or not a phone fetches one: 8,016,714 bytes across 112 assets against the 9 MiB ceiling, 84.9%, 1,420,470 left. It was 4,539,090 before item B, and the engine is the difference. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the native fallback under suppressErrorRendering The shared config reaches the phone too, and it gained a key the native path did not have. So the native document is now loaded for a diagram that throws, in both engines, with window.ReactNativeWebView standing in for the host: mermaid's run still rethrows, the document's own catch still posts `error`, and that is the message the component turns into the source box. Measured both ways, so the case says which half the key owns. Whether the fallback fires does not depend on it -- `error` is posted with the key and without it. What depends on it is that nothing is drawn behind the fallback: removing the key leaves mermaid's own error diagram in the document and reds this case at 1 SVG against 0, on chromium and webkit alike. The control is the same document for a diagram that parses: a height, not `error`, and one SVG. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one walk for every source census, without build output Nine censuses under mobile/src each held a copy of the same recursive walk, and each decided for itself what a source file is: seven had no opinion about generated files, one excluded them in its own regex, and one had the exclusion I added last round. So all nine read 7.9 MB of emitted vendor code -- 3.7 MB of mermaid for the WebView, 3.5 MB of it for the page -- and the two largest censuses TypeScript-parsed all of it, looking for call sites nobody wrote and nobody can move. That is what took rpc-params-contract-type-only-boundary over its 5 s timeout in CI once the fifth artifact arrived. Measured here, median of 3, import plus tests: main, 4 artifacts, no exclusion 1004 ms (slowest case 831 ms) with the 5th, no exclusion 1513 ms (slowest case 1358 ms) with the 5th, this commit 947 ms (slowest case 788 ms) So it lands below where main has it, not merely below where I left it. Across the nine, four more halve: rpc-operation-cast-fence 769 -> 441, rpc-subscription-boundary 946 -> 468, unchecked-rpc-reader-boundary 1042 -> 538, lifecycle-owner 747 -> 433, reanimated-web-mapper-deps 1028 -> 516. The two that already excluded generated files do not move. What each census counts as interesting -- extensions, whether test files are in -- stays its own, because they genuinely disagree. What counts as a source file at all is now said once. The control is the file that started it: a *.generated.ts whose text holds exactly the import a census is hunting, planted beside an ordinary file carrying the same text. The generated one is not returned and the ordinary one is, so the absence is a measurement. A second control reads mobile/.gitignore and holds the predicate to every artifact the tree generates, and a third fences the walk itself to one spelling, so a tenth census cannot paste the cost back in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct why the type pin sits in the product module The comment said a type-only pin in a test file "is never compiled". That is false: mobile/tsconfig.json excludes *.test.ts, but tsconfig.test.json is a second program that does check them, run by check:tests-typecheck and held by the tests-typecheck ratchet. The conclusion is unchanged and the reason is now the true one. The app's own typecheck is the unconditional gate and would not cover a pin written in a test; the test program is real but carries a grandfathered baseline and a few files held outside it on purpose. And the assertion is about this module's own type either way, so it belongs beside it. Comment only; tsc, the ratchet and both lints re-run on the file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
c77a82f783 |
feat(mobile): fire the page's haptics over the bridge notify (OTA phase C, C7.10 E) (#21864)
* feat(mobile): give the page a haptics notify and the grant that gates it `native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly the five `src/platform/haptics.ts` has, and the single token `haptics` joins `BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb because nothing is owed back: a reply would spend a slot in the same 64-deep in-flight window a forwarded request does, and there are 90 call sites in this app, some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30). The arm's fields live in their own module because `bridge-envelope.ts` is at its line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays in the envelope, so the fields are spread in beside it rather than reading it back through an import cycle. The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other device-local notify does: the host is the protocol's side of the bridge and a static import of the app's haptics would put `react-native` and `expo-haptics` in its graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping — `haptics.ts`'s own functions, its `Platform.OS` split and its Android `HapticFeedbackConstants` untouched. The dispatch branch rides along with the union rather than waiting for the page side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over that union, so the shell does not compile without it. That is the totality working, and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is. Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin and the missing-row type error all failed against the tree before this. Control on the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): post the page's haptics over the notify instead of doing nothing `haptics.web.ts` stops being five no-ops. Each of the five posts its own kind through the notify seam the entry publishes — the same shape `publishExternalLinkOpener` has, and for the same reason: every caller is a plain function inside a row's press handler that no provider wraps. `notifyHaptics` joins the page client beside the other gated notifies and answers whether the frame left, which nothing reads: a tap that did not buzz is what the page did before this, and a warning per refusal would be one per row of a scrolling list. Measured off the frame the client posted rather than a written copy of its shape, which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection / success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`, and a twelve-row scroll 888 bytes across twelve frames. The `web-overrides.json` reason now says what the file does instead of what it declines to do. Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a function`, and the six client cases on `notifyHaptics is not a function`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): grant haptics on every page route, with a census that derives the list All five declared page routes carry the `haptics` grant, and the list is a measurement rather than a hand choice: `WorktreeListRow` is in every page closure and calls the seam, so a route without the grant is a page whose taps stop buzzing with nothing on screen to say why. Grants are resolved once from the route the shell opened and held for the session, so the declaration is the only place to fix it. `mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one: it reads the kinds off the tuple that declares them, finds every exported `trigger…` function in a haptics module, and reports the kind each one posts. The posting call is found through the binding `publishHapticsNotifier` assigns rather than a local spelled `post`, because a rename would otherwise turn every posting site into a non-posting one and leave this green on a page with no haptics at all. The census proper holds each route's closure to the `.web.ts` sibling, asserts at least one importer so the grant is not idle, and derives the granted-route list from the closures. The control is the design's: the same walk over the native sibling finds the same five functions and no posting site, so "all five post" is a number rather than an empty scan. Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post reds 1 of 23. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record what the haptics notify costs a page closure One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it arrives through `page-route-policy.ts` reading the grant token rather than through the seam, whose import of the kind type is erased; its only dependency is `zod`, which the envelope already put in every closure, so the module total moved by the same one. Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336. Pinned structurally rather than as a total, because an absolute closure count is main's to move and a number that drifts for unrelated reasons is one nobody reads. The call sites this replaces, measured over product modules: `triggerError` 43, `triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10, `triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus `page-haptics.ts` itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): carry the haptics grant into the shell's two grant pins `bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over and reaching the real module pulls in an Expo runtime this test does not have, which failed the whole suite at import. Which expo member each kind reaches stays in `page-haptics.test.ts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): map each haptic kind to a named import, not a namespace index The changed-code gate refuses a computed reference into an imported namespace, in both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()` is a call nothing can follow. Each function is a named import instead, which also keeps the second compile-time direction — a row naming something `haptics.ts` does not export is now an import error rather than a `keyof` mismatch. The third direction moves with it, from a namespace read in the test to the census that already reads both files' text: `hapticsImportedNames` names what the shell's mapping takes from the app's haptics, and the census holds that to the five the native file exports. So a haptic added there with no kind of its own still fails, and now it fails where the other two siblings' names are already compared. The test's two `as` assertions become one annotated hoisted type, the shape `MobileWebShellScreen.test.tsx` uses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the true reason the haptics grant is one token `GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>` with lowercase segments, which is why it rejected `native.media.readChunk` and rejects `navigate-back`, not a dotted name as such. So four comments claiming a route declaring the notify's own name would have its bundle refused were false, and they are gone: the grant is a token because the notify table's grants are tokens, a notify not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from the verb table alone. Also folded, with the false claim: `implementedPageRoutes` filters on `grants.every(implementsGrant)`, so a token every page route declares couples the whole set to a shell that carries it — against one without it, no page route is served at all and the phone renders five native screens. Stated in the function's docstring and beside the census's derived list, and pinned: the same declaration under a grant this build does not implement comes back empty, with the token-free route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS` reds that case. `%#` consumes no argument, so the web seam's five cases were titled with the whole function body; the kind is the first element now and `%s` names it. One 110-char comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two still over it there are main's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
169bccc544 |
fix(mobile): restore query-string's named exports under the 9.5.1 override (#21727)
#21652 overrode query-string to 9.5.1 for GHSA-vcc3-ghjq-m6fr (decode-uri-component <= 0.4.2). 9.x's entry exports only `default`, while expo-router's linking forks, @react-navigation/core and @react-navigation/native all `import * as queryString`, so `stringify` and `parse` became undefined: any push carrying a param outside the path pattern and any href with a query threw. Patch the entry to re-export the named API; the override and the advisory fix stay. The lockfile carries the patch hash only; regenerated by hand because a non-frozen install re-resolves peer suffixes across the file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
bd860f34c1 |
fix(mobile): normalize OMP terminal momentum across refresh rates (#21687)
Use elapsed animation-frame time for OMP terminal momentum and update the generated payload contract. |
||
|
|
ec82173130 |
feat(mobile): mount the terminal document in the page over its own modules (OTA phase C, C7.5) (#21809)
* 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 * 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 * 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 * 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 |
||
|
|
9fbdfc592c |
refactor(mobile): generate the terminal WebView document from typed modules (OTA phase C, C7.1) (#21804)
* 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
* 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
* 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
* 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
|
||
|
|
d86d5cbbee |
fix(mobile): settle browser dialogs on the stream that reports them, map taps through the page scale, and sweep the frame budget on the real encoder (OTA phase C, C6.7) (#21799)
* fix(browser): settle a page's dialog on the stream that reported it (OTA phase C, C6.7) Chromium hands `Page.javascriptDialogOpening` to one CDP session and takes the answer only from that session; a client attaching afterwards is told `No dialog is showing`, and every renderer-bound command it sends first blocks behind the dialog it was sent to clear. Measured on Chromium 1217, 2026-09-20. `browser.dialogAccept` and `browser.dialogDismiss` went to the agent-browser bridge, which is always a later client, so the reply never reached `Page.handleJavaScriptDialog`: the page stayed blocked and its next dialog never opened. The screencast is the session that reported the dialog, so it is the session that answers it. With no stream live on the page the bridge path is unchanged. No RPC shape changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the browser dialog card until the page takes the answer (OTA phase C, C6.7) The card was cleared on the press, before the reply was sent. A page blocked on a dialog is still blocked until the host settles it, so the pane reported an answer the page never got and left the user looking at a stream nothing could move. The host's `dialogClosed` is what says the page took it, and that already clears the card. The port-pair case runs the pane against a host that only moves the page when the dialog is answered: alert, OK, the card stays while the reply is in flight, then the confirm raises its own card and resolves with the button's value. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): map a tap through the page scale the frame was painted at (OTA phase C, C6.7) Mobile view emulates a phone viewport, and a page with no `<meta name="viewport">` lays out at Chromium's 980 px default and is scaled into it. The frame metadata says so: `deviceWidth` stays the emulated width and `pageScaleFactor` carries the ratio. The browser's input commands take page CSS pixels, so a tap sent in the frame's own device space landed at that fraction of the aim — 41% on the phone, which is how the C6.6 proof found it. The frame geometry now carries the scale the frame was painted at, and both the tap map and the finger-sized click radius go through it. Measured on Chromium 1217, 2026-09-20: `scrollOffsetX/Y` must not be added — the frame is the visual viewport and the commands take viewport-relative coordinates, so a click sent at `device / scale + scrollOffset` landed a screenful past its target while `device / scale` hit it. Web view mode reports a scale of one and is unchanged, and so is a frame whose metadata carries no usable scale. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): sweep the frame budget on the encoder the product runs (OTA phase C, C6.7) The C6.5 sweep encoded through `canvas.toDataURL` while the pane's frames come from `Page.startScreencast`, so the certification and the product were measuring two encoders. The sweep now drives the screencast, over the same 143 viewports and the same noise, through the same real shell. The two encoders agree to within a thousandth of a byte per pixel, and the screencast is the cheaper of them: across the 111 viewports the budget fits it measured 0.543986 to 0.552964 bytes per pixel, against 0.54470 to 0.55351 from `toDataURL`. `WORST_CASE_JPEG_BYTES_PER_PIXEL` stays 0.56, now stated as the screencast maximum plus 1.3%. So the encoder is not what made the C6.6 device proof drop 1 frame in 41 at 402x593 with the budget on. That frame needs about 0.5649 bytes per pixel, above everything either sweep has seen, and nothing here reproduces it. The docstring records that rather than folding it into the constant. The frame is emulated at one device pixel per CSS pixel and the page carries a viewport meta: headless Chromium composites at the DIP surface size whatever `deviceScaleFactor` says, so without both the canvas is scaled into the frame, the noise averages away and the sweep reads about 0.12 bytes per pixel. Also records what C6 ruling 1 costs, in the pane's docstring: "never dark" holds only for a page that produces some frame that fits. With every frame over the cap the pane sits on its busy spinner over an unpainted viewport, which is the budget's reason for existing. No code change for that. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait on the double buffer's flip, not on a digest (OTA phase C, C6.7) The render check waited for some painted layer to carry a digest other than the previous frame's. `applyFrame` writes the next frame's URI onto the hidden layer as soon as the frame lands and only flips the opacity once the decode resolves, so that predicate is true before the frame is on screen. Measured with a MutationObserver over the style writes, 2026-09-20: the URI landed 80.7 ms after the emit and the flip at 85.7 ms, a 5 ms window in which the wait returns and the visible layer is still the previous frame. The check usually outran it by the round-trip it spends reading the layers back, which is why it failed once in CI (#21790, |
||
|
|
bd5177801b |
feat(mobile): put the page's pickers, paste and editor fallbacks on the media verbs (OTA phase C, C7.6) (#21795)
* feat(mobile): put media picking behind a platform seam (OTA phase C, C7.6) The session screen picks images three ways — the photo library, Files, and the pasteboard — and all three are native modules a page cannot import: the codegen lookup `expo-image-picker` and `expo-document-picker` run at import throws in a browser, and the route manifest imports every route, so one of them in a page closure is the whole bundle down rather than one picker. `src/platform/media-picker.ts` is the phone's, delegating to the same three calls the screen already made. `.web.ts` is the page's: `native.media.pick`, then `read` in order to `eof`, then `release` for every handle it was handed, including the ones its caller never took — the shell holds eight staged files at a time and an abandoned pick otherwise waits out the five-minute TTL. A refusal rejects with the shell's code on it and is never folded into the empty answer that means the user cancelled. The bytes are concatenated decoded and encoded once, because the wire promises `eof` and nothing about the length: a shell answering a range shorter than the one asked for ends a chunk on a partial base64 group, and a reader joining the strings would fold that padding into the middle of the file. The census walks the session route module's own closure — the route is not registered until C7.7 — and names any module that reaches a picker or `Clipboard.getImageAsync` directly. Today that is the two modules C7.6's next commit moves, listed by name so the list goes empty rather than the rule going quiet. Inert: nothing calls the seam yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): put the session's paste and attach on the media seam (OTA phase C, C7.6) The terminal paste read the pasteboard through `expo-clipboard` directly and the two attach paths called `pickMobileImage`/`pickMobileImages`, so the page's closure carried `expo-image-picker` and `expo-document-picker` — native modules whose import throws in a browser. All three now go through the seam. Text is `native.clipboard.read` on the page, which the clipboard seam gains a reader for: `expo-clipboard` resolves to `navigator.clipboard` there, which needs a secure context the iOS shell's custom scheme is not. An image is `pick { source: 'clipboard' }` rather than an inline value, because a clipboard image is 24 MiB of base64 against an 8 MiB reply ceiling. The census over the session closure is empty now and asserts the seam is in it, so a rule that found nothing is one that had something to find: with the three call sites restored it names all three. `mobile-image-source-picker.ts` stays the phone's implementation, reached only through the seam's native sibling, and resolves out of the web closure entirely. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): resize a clipboard image on the page with a canvas (OTA phase C, C7.6) The paste hook carried the raster shrink inline over `expo-image-manipulator` and two `expo-file-system` writes. Both are native: the manipulator has no browser build, and the temp file exists only to work around an iOS loader that cannot decode a large base64 data URI, which a browser does not need. Split into `mobile-clipboard-image-resize.ts`, unchanged, and a `.web.ts` that decodes one `<img>` from the data URL the shell's `img-src 'self' data:` already admits, draws it into a canvas at the target size and reads the PNG back out of `toDataURL`. It reports the canvas's own size rather than the size asked for, because a browser clamps a canvas past its area limit and the downscale loop above would otherwise retry a raster that never shrank; and it awaits `decode()` rather than `onload`, which never fires for a source the browser cannot read and would leave the paste waiting on a promise nothing settles. Measured in Chromium under the shipped header, on a noise PNG because that is what PNG compresses least: 1400x1000 encodes to 5,476,032 base64 characters and converges in one pass to 368x263 and 397,220, which is 75.8% of the upload path's 512 KiB chunk. Zero policy violations and zero page errors. Red under three mutations: the source returned unchanged, a reported size the canvas did not draw, and `onload` in place of `decode()`. `computeMobileClipboardImageDownscale` moves to a leaf for the reason the upload-chunk constant has one: the check wants the arithmetic and not the upload path's RPC operations behind it. The page closure now carries none of `expo-image-picker`, `expo-document-picker`, `expo-image-manipulator` or `expo-file-system`, pinned beside the seam census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the two WebView editors their plain web fallbacks (OTA phase C, C7.6) `MobileRichMarkdownEditor` and `MobileHtmlPreview` are the session closure's other two `react-native-webview` consumers. On the web that package renders the line "React Native WebView does not support this platform" where the surface was, so nothing it was mounted for works and the closure pays for a module that cannot do its job. Ruling 8: each gets the plain state it already degrades to, and no second renderer. The editor renders the Markdown source in one field on the text-input seam, so the screen around it keeps the text, every edit through `onChange`, and Save, Discard, Copy and Refresh; the degradation is the formatting toolbar, whose fifteen commands are the rich document's. The preview renders its own Source tab; the degradation is the rendered artifact, and the toggle goes with it, because a control that can only be in one position is a control that lies. Neither is smaller than a DOM renderer, which is why neither is one here. The editor's toolbar would need a `contenteditable` implementation with its own escaping, and the preview has no nested frame to sandbox agent-produced HTML in at all — the shell's policy carries `frame-src 'none'` and `child-src 'none'`. `dismissKeyboard` blurs the field rather than calling `Keyboard.dismiss`, which is a stub on React Native Web; `onKeyboardInsetChange` is never called, because it exists to correct for a WebView's covered area and on the page `keyboard-occlusion.web.ts` is the only measurement there is. The closure census names the one consumer left, `TerminalWebView.tsx`, which is C7.5's: with both siblings removed it names all three. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a destructured clipboard alias in the media census (OTA phase C, C7.6) The census recognised `Clipboard.getImageAsync` as a property access and nothing else, so `const { getImageAsync } = Clipboard` reached the same function without ever writing one and the closure was approved. On the page that call is `navigator.clipboard`, which needs a secure context the iOS shell's custom scheme is not, so the approval was for a path that dies at the browser clipboard API. Aliases are now resolved to a fixpoint — `const pasteboard = Clipboard` makes `pasteboard` the module too, and the chain has no length limit — and a destructuring off any of them is reported at its declaration, which is the line to delete. The destructured name is read the way the import clause's is, off `propertyName` when the element renames it, so `{ getImageAsync: readImage }` is the same offence spelled differently. A binding element's `name` can be a nested pattern and a `propertyName` can be computed, so the text is taken only off a node that has one. Red-first with each shape planted in the scratch tree before the rule moved: the plain destructuring, the renamed one and the re-destructured chain were all missed. Dropping the fixpoint afterwards loses the chain; reading the local name instead of the property loses the rename. `{ getStringAsync } = Clipboard` stays unreported, because text off the pasteboard is the clipboard seam's and not this rule's. The session closure is still empty under the widened rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): release every item a clipboard pick answered (OTA phase C, C7.6) `readClipboardImage` destructured the first staged item and released only that one, while `pickImage` already guards the same shape through `readPicked`. `multiple: false` is what the page asks for and not what a shell promises, so a caller taking the first of several would hold the rest against the eight-handle cap until the five-minute TTL. Today's shell stages at most one on the clipboard arm, so this is the seam's own docstring made true rather than a leak in the field. Red-first with two staged clipboard items: releasing only the one read leaves `media-2` held, and the second is now returned without ever being read, which is what the single-image pick does. The refusal case is one path over both codes a pick can answer with: the registry's `native_media_handle_cap`, raised before a picker runs, and ruling 6c's `native_media_too_large`, raised once a picked item has been weighed. A code outside the seam's vocabulary floors to `native_verb_failed` rather than crossing verbatim, which is what makes naming the exact code load-bearing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the upload chunk from its module in the resize check (OTA phase C, C7.6) The canvas resize check restated `512 * 1024` as the budget it holds a run to. A check carrying its own copy of a product constant is one that goes on passing after the upload path's chunk has moved, which is the reason the harness reads the CSP, the protocol version and the window caps out of their own sources. `readClipboardImageUploadChunkBase64Chars` joins them, evaluating the product the way the window caps reader does. Proved live by moving the constant: at 64 MiB the run reds on the fixture no longer being over the budget, and it is back to 512 KiB here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read element access in the media census and correct two claims (OTA phase C, C7.6) Round 2, four lows. The census read `Clipboard.getImageAsync` and not `Clipboard['getImageAsync']`, which is the same call, the spelling a bundler produces, and the one a reader reaches for to get around a rule about dots. Element access with a string literal is now read the same way; a computed key is not, because its value is not in the source and guessing would report a line nobody can act on. The closure test cannot back this up — `expo-clipboard` legitimately sits in the session closure — so the scratch fixture is the whole of the evidence, and it reds with the arm removed. The fixture also could not tell the alias fixpoint from one source-order pass: every planted chain happened to be declared in the order a single walk learns it. `reverse-order-alias.ts` is declared back to front, and is valid at run time because the destructure sits inside a function the module body finishes before anything calls. Bounding the loop to one pass now reds it. The canvas resize justified reading its size back off the element by a browser clamping past its area limit. That is not what browsers do: the width attribute reflects whatever it was assigned, so the returned size is always the target. The real reason is narrower and is now what the comment and the override entry say — the dimensions and the bytes come from one element, so a caller's bookkeeping cannot describe a raster that was not encoded. The override entry also carried a stray apostrophe in `img-src 'self' data:`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what the clipboard contract shipped as, and read a backticked key (OTA phase C, C7.6) Round 3, three lows. The merge took main's `clipboard.ts` byte for byte, so its reader docstring still described the state C7.2 shipped: one verb on the web, and a page whose lack of an image verb degraded into the old path. The design that shipped is the other one — this seam owns the pasteboard on both platforms and the page's `readImage` runs `native.media.pick { source: 'clipboard' }` with the chunked read behind it. The prose now says that, and says that null still means an empty pasteboard while every other outcome rejects. The same merge left `clipboard` twice in the paste hook's dependency list, one from each side. Deduped. The census read a quoted element-access key and not a backticked one, so ``Clipboard[`getImageAsync`]`` escaped a rule that catches both other spellings. A template with no substitution is a string literal with a different quote, and reading only one of the two leaves the other as the way around. The computed-key plant could not see the literal-kind check at all: its variable was named `key`, so reading the identifier's text found nothing either way. It is now named after the method and holds a different one, which makes dropping the kind check a false positive on a call that reads text. Red-first: the backticked access planted before the rule moved is missed; ignoring template keys afterwards misses it again; accepting any key node reports the computed plant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold canPickMedia to all three verbs and seed the census from import() (OTA phase C, C7.6) Two bot findings. `canPickMedia` answered true on `native.media.pick` and `native.media.read` alone, but every image read releases what it picked. On a route without `native.media.release` the release rejects, the cleanup swallows it by design, and the staged file stays live to the five-minute TTL: eight pastes and the next pick is refused at the handle cap, with nothing on screen to say why. A route missing one verb has no working image path, so `contents()` now says so up front rather than after four of them. Red-first: a route granted pick and read but not release answered `image: true`. The census seeded its aliases from static import and export declarations only, so `const Clipboard = await import('expo-clipboard')` produced no offender — while the bundler resolves a literal dynamic import into the closure exactly as a static one. A dynamic import is now read wherever it appears: `await` and parentheses unwrapped, the assigned identifier seeded as an alias, a destructuring off one reported at its declaration, and a picker module reported at the call, since reaching one at all is the offence. A specifier that is not a literal is left alone, for the reason a computed key is. Red-first with all three forms planted and the seeding removed: the namespace alias, the destructuring and the picker import are each missed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): seed the media census from a backticked import() too (OTA phase C, C7.6 bots) CodeRabbit: `import(`expo-image-picker`)` is as static to the bundler as the quoted form, but the census read only a string literal specifier, so a backticked one joined the closure unseen. A no-substitution template literal now seeds it the same way; the planted fixture is reported at its line and was unreported before the arm. pullfrog: the clipboard seam's docstring counted the web read as two verbs where its web sibling counts one for text and three for an image. It now counts the same way in both files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d5dc7b9cf8 |
feat(mobile): budget the terminal snapshot on serialized bytes and hold live output instead of ending the stream (OTA phase C, C7.3) (#21785)
* fix(mobile): budget the mobile terminal snapshot on the bytes it serializes to (OTA phase C, C7.3, ruling 1) The desktop trims a mobile snapshot to 512 KiB of raw terminal text. A client reading it through the page bridge measures the serialized event against a 640 KiB frame cap, and an ANSI snapshot is mostly ESC bytes, each of which JSON spends six on. Measured here on a colour-dense 80-column screen: the raw budget hands back 465,766 bytes that serialize to 669,268 — 102.1% of the cap — so `deliver` answers `cancel(id, 'overflow')` and the terminal is dead before its first live byte, with no recovery that does not reproduce it. `terminal.subscribe` gains an optional `snapshotByteBudget`. A subscriber that sends one is trimmed against the JSON its payload will really cost: the escaped text, plus the metadata it cannot bound from its own side — a path, the OSC-link list, the pending escape tail. A subscriber that sends none, which is every socket client and every older page, keeps the raw byte rule exactly. No negotiation, and none is needed: the field is additive and optional, so an older desktop ignores it and trims as it always did. The page then still has a snapshot over its cap, the shell still ends the stream with `overflow` (C0.3 stands), and the terminal renders its stream-error state rather than a blank pane. The page derives the number from the cap less the event envelope rather than writing it down, so a cap that moves takes the budget with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): hold and coalesce terminal output instead of ending the stream on the window (OTA phase C, C7.3, ruling 2) The shell's backpressure window ends a stream when the page falls 4 MiB behind. That is right for a stream whose reader can survive a gap and wrong for a terminal, whose reader cannot see the hole a dropped chunk leaves — and the window does not wait for a page to go wrong. Measured by the design: the host produces 70.3 MiB/s of JSON and real xterm applies 2.2 MiB/s, so an ordinary `cat` crosses the window in 62 ms. Replayed here through the real ledger against a page draining at that rate, a 5 MB transcript ends the stream after 85 of 107 chunks plain and after 40 of 107 under `grep --color`. Keyed by method on the shell, since the page cannot pick its own window, `terminal.subscribe` now holds what it cannot send, merges consecutive output in escaped bytes under the frame cap, and delivers as the page acks. Nothing is dropped: merging concatenates, and the only exit that loses bytes is ending the stream, which the page is told about. Both transcripts now arrive whole and in order, in 104 and 81 frames, with the largest frame at 622,551 bytes against the 655,360-byte cap. It ends only on the two things that are not slowness: a page that has acked nothing for 20 s, an order of magnitude above the 1.9 s a full window takes to drain, and a backlog past 32 MiB, which at that drain is about 15 s of catching up. Both reach the page as `overflow`, because the shell is the installed app and its page comes from the desktop, so a reason the page's reader has never heard of is a frame it drops rather than an end it acts on. Which one fired, the coalesced-frame count and the peak pending bytes go to the diagnostic log, which is the device proof's only oracle for any of this. Every other stream keeps the byte window exactly, and an event over the frame cap still ends any stream, terminal or not (C0.3). The landed window cases now name a stream the window still governs, so the two rules are never read off each other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): narrow the event arm the backlog replay reads A binary event carries no `payload`, so the tests-typecheck ratchet refused the reach into it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): narrow the snapshot serializer to the buffer source it reads The changed-code casting gate refused the test's stub runtime, and it was right to: a service-wide type for a function that calls one method is what made the stub need an assertion. The parameter now says what it needs, and the fixture path is no longer one a machine-path grep reads as a leaked local checkout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): measure the snapshot budget by building the payload, not by summing fields (OTA phase C, C7.3, ruling 14) Round one summed the escaped text and four metadata fields. The payload a bridged client assembles carries nine more — `kind`, `cols`, `rows`, `requestId`, `displayMode`, `reason`, `seq` and both truncation flags — plus the `type` and `streamId` it adds, the `serialized` key and the object's own braces. So a snapshot this host accepted at exactly the budget, with `truncatedByByteBudget` false because nothing had trimmed it, published over the cap and the stream ended with `overflow` before a byte was painted. Measured here on a screen sized to land exactly on round one's budget: the published payload is 655,446 bytes against a 655,273-byte budget, 173 over, and the frame it makes is over the 640 KiB cap by the same amount. The metadata is now built by one function that `sendSnapshotFrames` and the budget both call, and the budget stringifies the payload that function produces. Nothing is summed and nothing is estimated, so a field added to the frame is paid for by the budget the moment it is sent. Where a value is not yet known — the truncation flags, and `seq` or `requestId` at a site that has not fixed them — it is measured at the widest `JSON.stringify` can write it, which is a bound rather than a guess, and forcing `seq` to a number also opens the three fields it gates so those are counted too. The budget therefore travels with the publication fields, because the payload cannot be built without them. On the page, the event envelope is now derived in one place in the protocol module and read by both the snapshot budget and the shell's own merge budget, so the two cannot drift; the page pins the number it sends and the host's cases name that pin, since the two programs cannot import from each other. The case that re-implemented the host's measure is gone: it could not have seen this, because it was the same arithmetic twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): arm a held terminal's silence clock only while something is pending (OTA phase C, C7.3) The invariant is "armed implies waiting on the page", and round one broke it in the one direction that kills: an ack re-armed the clock and the drain that followed emptied the queue without clearing it. A terminal that had delivered every byte and gone quiet — which is what a terminal does between commands — would die on `overflow` twenty seconds later. The clock is now synchronised after every change to the queue, so it is armed exactly while something is held. A rule that only ever arms is a rule that only ever ends more streams. Red-first: with round one's arming, an idle stream whose queue has drained still reports its clock armed, and firing it ends a healthy terminal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the held-stream cases the rulings name (OTA phase C, C7.3) Six cases nothing covered. Two subscriptions on one shell keep separate backlogs, so a busy terminal cannot end a quiet one. A stream the page unsubscribed mid- backlog posts nothing after, and neither does one that has already ended, however much was still held. A payload that is not output breaks a merge run and keeps its place, because a resize is state the reader applies in order. And the budget boundary is checked on the side that enforces it: a payload at exactly the number the page asks the desktop for is delivered inside the cap, and one the cap cannot hold ends the stream under C0.3. The replay no longer acks unconditionally in its catch-up loop. That was the page behaving better than a page can — it acks on reading frames — and it is what hid the silence clock left armed over an empty queue. The held-stream cases close the window on its frame count rather than on four megabytes of string work. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the event-envelope derivation its own module (OTA phase C, C7.3) `bridge-envelope.ts` is at its line cap and is the protocol's schemas; what a frame costs around its payload is a derivation over them, and two budgets read it — the snapshot the page asks the desktop for, and the output the shell merges. One module, so they cannot drift and neither file is pushed over its limit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: narrow the budget fixtures instead of asserting them The changed-code casting gate refused six `as NonNullable<...>` in the new budget cases, and it was right to: a fixture that serialized nothing is a broken case rather than a null to assert away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor: give the snapshot payload shape its own module (OTA phase C, C7.3) `terminal-snapshot-publication.ts` crossed the root config's 300-line cap, which mobile's own lint does not apply and CI does. The frame's shape and what it costs a client reading it as one payload is a description the budget and the sender both need, so it is the part that leaves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix: empty a snapshot the budget cannot fit instead of posting it over (OTA phase C, C7.3, ruling 15) Both trimming loops published the zero-row candidate whatever it measured, and zero scrollback is not a small screen: a wide colour-dense viewport still carries its 24 live rows. A capped subscriber could get one frame over its cap, end the stream on `overflow` and paint nothing — worse than a blank terminal, because a blank one repaints on the next byte of output and a stream that never opened does not reopen. Ruling 15: a budgeted subscriber gets that frame with its text emptied and `truncatedByByteBudget` true, never over and never refused. The raw rule keeps its fallback, so an older page and every socket client are served exactly what they were before. Below the metadata the frame must carry there is nothing left to give up, and that boundary is pinned rather than claimed away. The renderer loop is the same walk reached by a different caller and had no test at all; its runtime parameter is narrowed to the two methods it reads so a case can stub it without a cast. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report what a held terminal stream did instead of calling it an outlived view The backlog report had no branch in the reporter, so it fell through to the "a view outlived its host" warn and every field it exists to carry was discarded. The key made it worse: keyed by kind alone, one backlog per host was ever logged, and a shell holds one stream per open terminal. That report is the only oracle the coalescing rule has. Nothing crosses to the page saying how much was held or how many frames its bytes arrived inside, and both ways a held stream dies reach the page as `overflow`, because a reason its reader has never heard of is a frame it drops. In production the two rules were indistinguishable. They are now a line each, per stream. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: give the renderer fixture the source its serializer returns `serializeRendererTerminalBuffer` answers `renderer`, and vitest does not typecheck, so the stub's `headless` passed every run and failed the node typecheck instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix: budget the frame the publication actually sends (OTA phase C, C7.3) The budget and the publication were written out twice, five lines apart, and had drifted at every site: a budget for `{kind:'scrollback'}` approved a frame sent as `kind:'resized'` with a `reason` beside it, and the live module budgeted `pending-output-overflow` while sending `renderer-mount-ready`. It held only because the padded `requestId` and `seq` are absent from those frames and more than covered the difference. Each site now builds one object and hands it to both. `displayMode` cannot travel that way and was a third under-measure nobody had named: the subscribe flow re-reads it from the runtime after the snapshot is serialized and before the frame is sent, so no caller can tell the budget which mode the publication will carry. It joins `seq`, `requestId` and the truncation flags as a field taken at its widest. The mode list resolves the constant to `never` if the runtime gains a mode it does not carry, so a new one is weighed here rather than found on a phone. Red-first needed a second attempt: the first fixture had trimming slack, so three extra bytes fit and the probe could not see the defect it was written for. The case now budgets a fixed screen at exactly its `auto` measure, where the margin is the whole of the test. One figure for the overshoot everywhere, with its basis: 169 bytes over the 655,360-byte cap on a frame carrying an 8-character request id, 247 with a 24-character one. Three places said 169 and one said 173. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): delete two backlog guards no input can reach Both survived mutation because neither is reachable, and neither became reachable when I tried to write a case for it. `next` narrowed the merge ceiling to one frame, but its only caller, `drainTerminalBacklog`, has already narrowed it: the parameter is what one payload may occupy, not what the window holds, so the second narrowing could never change the answer. The parameter now says so and the class no longer needs the frame size at all. The bound still lives in the caller and is still covered: removing it there reds a delivery case. The merge run also compared stream ids, but a backlog belongs to one subscription and every `data` payload on it carries that subscription's single stream id, so the comparison could not fail. The run still stops at anything that is not output, which is reachable and pinned. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the invariant the deleted stream-id guard rested on The merge run compares no stream ids because it cannot need to: a backlog belongs to one subscription and every `data` payload reaching it carries that subscription's single stream id. Written down where the run is, because the thing that would break it is a change made somewhere else — multiplexing two streams onto one record would merge their output into one payload under the first id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e736a9f29b |
feat(mobile): take the session screen's inputs, links, clipboard and routers through the platform seams (OTA phase C, C7.2) (#21790)
* feat(mobile): put the session screen's nine text inputs on the web font seam (OTA phase C, C7.2) The landed text-input census, run over `app/h/[hostId]/session/[worktreeId].tsx`, reports nine sizes that do not come from `TEXT_INPUT_FONT_SIZE`. Six declare the app's body size and move in place, which is the same number natively. Three do not — a 22px key-capture field and the chat's two 15px fields — so each gets a `.web.ts` sibling of the address bar's shape, with a shared base so the two halves can differ in nothing but the size. The capture field is the one the move shrinks rather than raises: 22 already clears the focus-zoom floor, and the census reads the seam as a binding rather than as a number, so there is no expression that keeps 22 and still says where the size came from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the theme import the composer's style split left behind `oxlint` over the whole tree, which CI runs, reads it as an error. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): open the session screen's three external URLs through the platform seam (OTA phase C, C7.2) The landed external-link census, run over the session route's closure, reports three modules reaching react-native's `Linking`: a terminal link tap whose open mode is the phone's browser, and the two WebView-backed readers, each of which sends a tapped link to the system browser rather than navigating the artifact away. Inside the shell `Linking.openURL` calls `window.open`, which both shells refuse and which resolves either way, so all three reported success into a tap that did nothing. The seam also stops swallowing the failure: each site caught and discarded, and `openExternalLink` names it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): take the session screen's seven clipboard sites through the platform seam (OTA phase C, C7.2) `expo-clipboard` resolves to `navigator.clipboard` on the web, which needs a secure context — iOS serves the page from a custom scheme and Android from `https`, so that path works on one platform and silently not on the other. The landed census now reports the module out of the route's closure entirely. The seam grows its reader half, on the landed `native.clipboard.read` verb: text, a PNG, and a presence probe. Two degradations are recorded rather than implied. No shell serves an image, so the page answers null and the terminal's paste takes the branch an empty clipboard already took; and the shell serves no presence verb, so `contents` answers what this side knows rather than reading to find out, which would raise iOS's paste-consent prompt on every foreground. The copy-path sheet gains the failure toast its two neighbours already had: it showed "Path copied" before the write, and the seam rejects rather than returning false. The route parity pin moves with it: five clipboard hooks join the expanded route and one runtime string joins the sheet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): take the session domain's three routers through the handoff seam (OTA phase C, C7.2) Inside the page a screen is one document standing in for one screen, and `useRouteHandoff` is the only thing that knows which targets the page keeps and which it hands back to the app. The three holders here are the workspace-missing bounce, the file-tap preview push, and the pane-tap param consume. The domain's census is narrower than the two landed ones because it has to be: eight of its hooks take `useFocusEffect` and two take `useLocalSearchParams`, neither of which can navigate, so the rule is a closed list of names rather than a ban on any value import — which also catches expo-router's module-singleton `router`, a spelling a `useRouter` rule would have read as clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the two style siblings the session screen's inputs added (OTA phase C, C7.2) The overrides census fails on an unlisted `.web.*`. One raises the chat's two 15px fields past the focus-zoom floor; the other lowers a 22px capture field onto the seam, and its entry says why a reduction is the right answer there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the text-input census read a literal already clear of the floor (OTA phase C, C7.2, ruling 12) The floor is the rule and the seam is the mechanism. A binding rule alone made the custom-key capture field an offender at 22, where nothing can zoom, and the only way to satisfy it was to lower a one-character field to 16 — the tail wagging the dog. The seam's web half now exports the floor it already computed `Math.max` against, and the census reads that number out of that file rather than carrying a second copy of 16. The rule becomes "the seam's binding, or a literal at or above the floor", with no per-site exemption: a literal under the floor is still reported, which is the case the seam exists for. A tree whose seam declares no floor is refused rather than judged against a number the census invented. So the capture field goes back to 22 on both platforms and its split, its override entry and its parity test go with it. The chat's two fields stay split, because 15 is under the floor however it is spelled. Red-first: with the rule removed, a planted literal 16 and a literal 22 are both reported and the refusal case does not throw; a literal 15 is reported either way. All three route closures that run this census — session, source-control, review — report 0 offenders and 0 unresolved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): answer clipboard reads on the read grant and catch refused writes `contents()` reported no text on a route that granted `native.clipboard.read` without the write, because it read `verbs.granted`, which is write AND read. The verbs hook now exposes the two grants separately and the web seam answers on the read one; `granted` keeps its meaning for the callers that need both. The Markdown copy action was the one write of eight in the session domain with nowhere for a rejection to go: the seam rejects when the pasteboard refused the text, the callback had no failure branch, and its caller drops the promise, so a refused write raised an unhandled rejection and still left "Copied" on screen. It now takes the error haptic and the "Couldn't copy" toast the other copy paths show. A census over `src/session` fails if any `writeText` call site lacks a failure branch, so the ninth site cannot arrive without one. The route parity pin moves with it: one callback body, one runtime string. Its refresh note claimed six clipboard hook sites for a delta of five; the walk from `SessionScreen` reaches five, and the terminal's paste is not among them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): probe both clipboard kinds at once and judge sizes against the floor Moving the two clipboard probes into an object literal serialised them: the migrated `contents()` awaited `hasStringAsync` before `hasImageAsync` was called, where both callers had used `Promise.all`. That path runs on mount, on every AppState foreground and on every select-mode toggle. Restored, with an ordering probe that deadlocks unless both probes start before either answers. The floor case could not fail for the reason it named: its fixture declared 16, so a census carrying its own copy of 16 passed it. It now plants a seam declaring 20 and a literal 18, the size that is clean under one floor and an offence under the other. Two stale wordings from the reverted split: one closure case still said "both split style modules" over a one-element list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): buzz the quick-command row when a copy is refused The last of the seven migrated writes without the error haptic. The row already said "Couldn't copy" on its own control, in red, for the 1500 ms the toast the other six show would have lasted, so it never claimed a refused write had landed; what it had no way to say was anything the thumb still on the button could feel. Its first test, on the harness its list already uses: the seam rejects when the pasteboard refuses, and the two cases are the difference between the row that shows a green check over nothing copied and the row that does not. The list's own test gains the haptics mock the row's new import needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the clipboard census require the await its rule depends on `hasFailureBranch` accepted any enclosing `try` with a `catch`, so the one shape the census exists to stop passed it: `void clipboard.writeText(...)` inside a try/catch is an unhandled rejection with a handler three lines above it that can never run, because the block returns before the promise settles. It now requires the call to be awaited inside the try's own block, or to carry a `.catch` along its own chain. The boundary walk stopped only at function and method declarations, so a `catch` outside an arrow answered for the call left running inside it; every function-like node ends the search now. Five cases over snippets read through the same reader, because a `void` write would have to be committed to be tested against the real tree. Control on a real site: making the Markdown write un-awaited inside its own try reports it. Two provenance fixes. The runtime-string delta across C7.2 is two literals, not one: "Couldn't copy path" took the count from 532 to 533 and "Couldn't copy" took it to 534. And main's C7.4 made `BRIDGE_CLIPBOARD_MIMES` `['text']`, so an image mime is a value the schema does not admit rather than a refusal the verb spells out, with `native.media.pick { source: 'clipboard' }` waiting on C7.6; the web seam and its test said otherwise. Behaviour unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let an unmounted quick-command row emit nothing on a refusal The haptic I added ran before the mounted guard, so a copy pressed on a row that then scrolled out of the list, or a sheet closed over it, still buzzed when the rejection arrived. A buzz with no row to explain it is feedback for nothing, and the guard was already there for the feedback state one line below. Red-first: press, unmount, then reject. The success path already guarded first. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the clipboard census require a catch with something in it Any `.catch` property access counted as a failure branch, so two shapes that handle nothing passed: `clipboard.writeText(text).catch` reads the handler's name and registers nothing, and `.catch()` swallows the rejection while the caller goes on to say the write landed. The rule now requires `.catch` to be the callee of a call carrying at least one argument. Red-first with both shapes in the snippet reader, the accepting cases unchanged. Control on the real tree: emptying the notes sheet's handler reports `MobileSessionSheets.tsx:174`, and restoring it greens. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e5181113ca |
feat(mobile): add the media verbs the page's pickers and paste will use (OTA phase C, C7.4) (#21779)
* feat(mobile): add the media verb rows to the shell's native table (OTA phase C, C7.4)
`native.media.pick`, `native.media.readChunk` and `native.media.release` join
`BRIDGE_NATIVE_VERB_NAMES`, so each becomes a grant name `init.grants.native` can
carry and each gets a strict zod contract in `bridge-media-verbs.ts`. A chunk read
is held to the upload path's own budget, imported rather than restated: the leaf
module `mobile-clipboard-image-upload-chunk.ts` now declares
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` so the bridge can read it
without pulling the upload path's RPC operations into every page closure.
Inert: no page calls the verbs and no route declares them, so an older page never
names them and an older shell refuses them as `unknown-verb`.
`native.clipboard.read`'s `{ mime: 'image' }` refusal is retired rather than left
pointing at nothing. The broad shape existed so a later build could serve an image
without a contract change; that build is `pick { source: 'clipboard' }`, which
stages the image behind a handle instead of inlining 24 MiB of base64 through an
8 MiB reply. The mime enum is text only, the handler's out-of-scope arm is gone,
and the refusal's test flips to the new answer: `invalid-params`, before dispatch.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the shell the staged-media handle registry (OTA phase C, C7.4)
A handle names a file this shell copied into its own cache, and such a file has no
owner otherwise: the page that asked for it is a document that can navigate, fault
or be swiped away without telling anyone. So the lifetime is bounded four ways and
all four are here — the page's own `release`, `MEDIA_HANDLE_TTL_MS`, and
`releaseAll`, which both the session's end and the page's unmount reach through
`useMediaHandleRegistry`. The TTL is measured from the last touch, not the mint, so
a page reading a large file one chunk at a time cannot have it swept out from under
it; its five minutes is sized for the page that picked and then stopped, not for
the read, which is 48 round trips on the largest item a pick may stage.
`BRIDGE_MEDIA_MAX_LIVE_HANDLES` caps what one session holds. A pick that would pass
it is refused whole and discards what it staged: half a multi-select is an answer a
page cannot tell from a user who picked fewer.
Three refusals join the seam's vocabulary. `native_media_handle_unknown` covers
never-minted, released and swept alike — which of the three it was is a fact about
another page's pick. `native_media_range` refuses a read at or past the end of an
item that had bytes, because the previous chunk already said `eof` and an empty
answer would let a page loop instead of failing where the bug is.
`native_media_handle_cap` is the cap above.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): serve the media verbs on the device, behind staged handles (OTA phase C, C7.4)
`pick` runs the OS permission prompt inside the shell — `expo-image-picker` for the
library, `expo-document-picker` for Files, the pasteboard for `clipboard` — stages
each item as a file this shell owns, and answers handles. `readChunk` reads the
byte range the registry hands it and base64-encodes it through the upload path's
own accumulator; `release` deletes the file.
The device calls live in `native-media-device.ts` and nothing else. Importing
`expo-image-picker` imports React Native, so a module naming it cannot be driven in
a unit test at all, and the arms worth pinning are exactly the ones a simulator
makes expensive: a denied permission, a cancel, an item over the staging ceiling.
`native-media.ts` takes those seven calls as dependencies and is tested whole.
Two refusals join the vocabulary, each because a page acts differently on it.
`native_media_permission_denied` is a permission the user can still grant in
Settings, not a library the shell could not read. `native_media_too_large` is an
item over `MEDIA_STAGED_MAX_BYTES`, weighed from the staged file rather than from
what the picker declared, since a picker's own size is optional on both platforms.
A pick refused that way discards every file it staged.
Each chunk is base64 on its own, so a page concatenates decoded bytes and never
strings: only the last chunk of a read ends on a partial group.
Measured: the largest reply this verb can produce is a full
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` chunk, 524,427 bytes on the
wire with its envelope — one frame, 80.0% of the 640 KiB frame cap and 6.3% of the
8 MiB reply ceiling. Pinned in `native-media.test.ts`, so a cap or an envelope
field that moves shows up as a diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): only mint a media handle over a file this shell owns (OTA phase C, C7.4)
The Android arm of the same handler. Both pickers are configured to hand back a
copy in this app's own cache — `expo-image-picker` always copies, and
`expo-document-picker` does under `copyToCacheDirectory` — and the whole handle
contract rests on it: `release` is a delete, and so is the TTL sweep. A provider
that answered `content://media/...` instead would mint a handle over a file this
shell can neither size nor delete, and every sweep would be a silent no-op that
leaves the cache growing. `ownsStagedMediaUri` refuses that where the assumption
is made rather than letting it surface as a cache that never empties.
`native-media-device.test.ts` pins the options that make the assumption true: the
document picker's `copyToCacheDirectory`, the library picker's `base64: false` and
its selection limit, the pasteboard's png, and the cache file `stageBase64` writes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): move a picked image across the real pair, chunk by chunk (OTA phase C, C7.4)
Through `createFakeBridgePortPair` rather than the host alone, because a chunk is
the one reply this seam produces that is within a rounding error of the frame cap:
a case that called the server directly would never serialize one, and serializing
is where a reply that fits and a reply that splits part ways.
A granted page picks an 18 MiB item, reads all 48 chunks in order to `eof` and
releases it; the decoded length is the file's own and both ends are the fixture's,
which a reader that dropped or reordered a chunk would not have. Nothing reaches
the desktop client. A page granted only `navigate` and `storage` is refused
`native_verb_ungranted` with no picker run — the refusal is the host's, because
the page side of these verbs is C7.6's. A released handle, a swept handle, a read
past the end and a pick over the live-handle cap each come back under their own
code.
The pair's default verb handler answered a clipboard shape for every verb, so a
media call through an unconfigured pair came back as `native_verb_result` — a
shell bug's code for a harness that was never told about the verb. It now answers
one shape per row of the table.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the four type assertions the changed-code gate found (OTA phase C, C7.4)
`new Array<T>()` for the two hoisted mock ledgers, and the probe holds its handler
in a record it null-checks rather than asserting one that a render might not have
produced.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): rename the chunk verb to native.media.read, which a manifest may name (OTA phase C, C7.4)
`native.media.readChunk` is not a legal grant name. `GRANT_NAME_PATTERN` holds every
`native.` segment to `[a-z][a-z0-9]*`, so a route declaring it fails
`MobileWebBundleRouteSchema` — and `bundled-mobile-web-bundle.ts` parses the manifest
whole, so the camel-cased segment is not a route that degrades to its native screen.
It is a bundle the phone rejects entire. Params are unchanged.
The test that would have caught it now reads the schema itself rather than a copy of
its pattern: every entry of `MOBILE_WEB_SHELL_GRANTS`, and every name in
`BRIDGE_NATIVE_VERB_NAMES`, parsed as the grants of a manifest route. Both lists,
because the spread is what makes them agree today and a build that stopped spreading
would leave this the only thing that noticed. Red on the old name with the exact
message: `native.media.readChunk: expected false to be true`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): exercise a short read, which nothing separated from the chunk cap (OTA phase C, C7.4)
Every other read in the suite asks for a whole chunk of a file shorter than one, so
a handler that ignored `length` and read to the end passed all of them: the fake
file clamps at its own size. Replacing the range with the cap left 74/74 green.
A 16-byte read at offset 400 of a 1000-byte item now pins the length, the offset and
`eof: false`. Under that same mutation it reds with `expected 600 to be 16`, and it
is the only case that does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): kill the four device arms that survived mutation (OTA phase C, C7.4)
Each was green with the arm removed; each now reds with it removed and nothing else
does.
The data-url strip: `getImageAsync` answers `data:image/png;base64,...`, which is
what an `<Image>` source wants and not what a file wants. Staged unstripped, every
byte is shifted by the prefix and the page decodes a corrupt image with no error
anywhere. The old fixture had no prefix at all.
The cancel flag: both pickers answer `assets: null` beside `canceled: true` today,
so a handler keyed on the list alone passed every fixture here. The new case sends
the flag with a populated list, which is what a picker version that changed its mind
would send.
The unknown mime: an empty string is not a mime the result schema takes, so the
alternative to the floor is `native_verb_result` — a shell bug's code for a document
picker doing what it may do.
The handle close: a ledger per opened handle, asserted on the way out and on the way
through a reader that throws. A file handle a shell leaks is invisible on a fake and
a file descriptor on a phone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the stated constants as literals (OTA phase C, C7.4)
The TTL, the live-handle cap, the handle and mime lengths, and the 48 chunks an
18 MiB item reads in. Literals, not the constants restated: every one is a number a
body claims and a reviewer checked, and read through its own name the assertion
would hold whatever it became — which is what the 524,427-byte reply pin already
does for the frame it measures.
Each reds when its number moves: 8 to 12, 64 to 32, 128 to 256, five minutes to ten.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the fixture by its size, assert the clipboard refusal, reach the clamp (OTA phase C, C7.4)
Three minors from round 1.
The case that said "18 MiB" staged a million bytes. Renamed to what it stages, with
a line saying the largest item a pick may hold runs in the port-pair suite, where
the frames are serialized and the size is the one that matters.
The clipboard image case had been weakened to a bare `.rejects.toThrow()`, which
would pass for a handler that reached the pasteboard and failed there — the one
outcome it exists to rule out. It now asserts a `ZodError` naming the `mime` path,
and reds when the parse is taken out.
The registry's chunk clamp was unreachable behind the params schema. Kept and
reached rather than deleted: the two bounds are different promises, one saying what
a page may ask for and one saying what the registry will hand any reader, and
`read` is a public method its own suite already calls directly. A case asks for
three chunks at once and gets one; it reds when the clamp goes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): copy a provider uri into the cache instead of refusing the pick (OTA phase C, C7.4)
`MediaHandler.readExtras` in expo-image-picker 55.0.24 has a reachable arm: when
`toMediaType` cannot resolve a MIME it answers
`ImagePickerAsset(type = null, uri = uri.toString())`, the provider's own
`content://` uri, uncopied. The OS completed that pick, so refusing it as
`native_verb_failed` loses a photo the user chose — and the comment above the guard
claimed both pickers "always" answer `file:`, which the same file disproves.
The pick now copies what it does not own into the shell's cache and mints the handle
over the copy, which is what the body already claimed. The guard stays for anything
the copy could not adopt, and that arm still fails closed: a sweep that can never
delete anything must not look like one that did. The failure path discards what this
shell owns rather than the uris the picker answered, since a provider's uri was
never ours to unlink.
Copied through `bytes()` rather than `copy()`: `FileSystemPath.copy` goes to
`javaFile.copyRecursively`, a `java.io.File` with nothing to open for a provider uri,
while the read path goes through the unified file and does. A source that cannot be
read takes the empty destination with it, because the caller never learns that name.
Red-first: a fixture answering `content://media/external/images/media/42` asserting a
handle over a `file:` copy, its 300 bytes read back whole, and the copy being what
`release` deletes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): bound a multi-select to the room the registry has left (OTA phase C, C7.4)
`selectionLimit: 0` means unlimited to the OS picker, and `mint` then refuses the
whole pick past `BRIDGE_MEDIA_MAX_LIVE_HANDLES`. A user who chose nine photos waited
through the OS copying every one of them into the cache to be told none were taken.
`pick` now reads `remainingCapacity()` before it launches anything: an empty room is
`native_media_handle_cap` up front, for every source, with no picker run and no byte
copied. What room is left becomes the picker's `selectionLimit`, so the selection
cannot exceed what the registry will accept and the refusal after the fact is only
reachable by a page that never released what it holds.
`getDocumentAsync` takes no selection limit, so for Files the room is the up-front
refusal only; `mint` remains the bound that cannot be skipped.
Each pin reds on its own mutation: dropping the up-front refusal, putting
`selectionLimit` back to 0, and fixing the limit at the cap instead of the room.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): ask for the library permission on iOS only (OTA phase C, C7.4)
Read from expo-image-picker 55.0.24, not from the docs. Neither platform's
`launchImageLibraryAsync` gates on a permission: `launchCameraAsync` calls
`ensureCameraPermissionsAreGranted` on Android and `hasGrantedPermission` on iOS, and
the library arm goes straight to its contract on both. On Android
`getMediaLibraryPermissions` answers an empty array from API 33, so the request
prompts nothing and always resolves granted; below 33 it asks for
`READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE`, which the system picker never
reads, and a denial there reached `native_media_permission_denied` for a pick the OS
would have completed. Android no longer asks.
iOS still does, because the prompt inside `pick` is what ruling 6 asked the shell to
own and the photo-library dialog is a real thing a user sees there.
One finding for the lead rather than a silent change: with `allowsEditing: false`
the iOS path is `launchMultiSelectPicker`, a `PHPickerViewController`, which also
opens without authorization. So the iOS request is the ruling's and not the SDK's,
and a denial refuses a pick that would have worked. Dropping it is a ruling-6
amendment, not mine to make.
Both arms pinned and both red under their mutations: asking everywhere fails three
cases, asking nowhere fails two.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mock react-native in the device-verb hook suite (OTA phase C, C7.4)
The hook reaches `native-media-device.ts`, which now imports `Platform`, and reaching
the real module imports React Native — which this suite has no runtime for, so it
failed to load and ran zero tests while the run still reported every other file
passing. The device half's own platform arms are `native-media-device.test.ts`; here
the OS only has to be one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): ask for no library permission on either platform (OTA phase C, C7.4)
Ruling 6b. `launchImageLibraryAsync` gates on nothing in expo-image-picker 55.0.24:
Android goes straight to its contract and iOS to `launchImagePicker(.photoLibrary)`,
and only the camera arm checks a permission. The prompt "inside the shell" that
ruling 6 asked for is the OS picker's own, so `pick { source: 'library' }` adds
none. `requestLibraryPermission` is gone from the deps, from the device calls and
from `pickFrom`, and with it the last way a denial could refuse a pick the OS would
have completed — Android below API 33 for storage permissions the picker never
reads, iOS for a `PHPickerViewController` that opens without authorization.
`native_media_permission_denied` stays in the refusal vocabulary, documented for the
first source that needs one rather than for a producer this build has.
Red-first, and driven through the whole verb rather than through `launchLibrary`
alone, since the request was `pick`'s and a case that only called the picker would
have passed either way: with the request restored, the pick on iOS calls the
permission API once and the deps still carry the member, and both cases red.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): sweep before release, and name one basis per ratio (OTA phase C, C7.4)
Two lows from round 2.
`release` was the only lifetime path that did not sweep first, so an expired handle
was still in the map when it looked and the page was told `released: true` for a file
the next sweep would have taken anyway — the opposite of what
`mediaReleaseParamsSchema`'s docstring promises it. It sweeps now, like every other
path that reads the map. Red-first with the reviewer's own probe: expired, then
released, answered true. The exact boundary is pinned while we are here, because the
sweep's comparison is inclusive and nothing said so: at exactly `MEDIA_HANDLE_TTL_MS`
the handle is gone, one millisecond earlier it is not.
The raw ceiling was called "three times the reply ceiling" in two places.
`CLIPBOARD_IMAGE_MAX_SOURCE_BYTES` is 18,874,368 against 8,388,608, which is 2.25;
three is the ratio after base64 expands it. Both sentences now name which basis they
are on, and say that the two differ.
Each pin reds on its own mutation: dropping the sweep from `release`, and loosening
the sweep's comparison to exclusive.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): never sweep a uri this shell does not own, and weigh before copying (OTA phase C, C7.4)
Round 3's five items.
(1) `owned.push(uri)` ran before the ownership guard, so a copy that could not be
adopted sent the provider's own `content://` uri to the failure path's discard — the
one thing the array's docstring says it never holds. The push now happens below the
throw, inside a named `adopt` that either hands back a uri this shell owns or throws
having claimed nothing. Red-first: the refusal case discarded exactly
`["content://media/external/images/media/42"]`.
(2) The copy's docstring claimed the in-memory read was bounded by a ceiling checked
"right after", which ran after the whole `bytesSync()`. The source is now weighed
before the copy, which is real: `FileSystemFile.size` routes a `content:` uri to
`SAFDocumentFile.length()`. A provider reporting nothing answers 0 there, so the
docstring also says what is true for that case — the read is bounded only by
`mediaTypes: ['images']`, and the copy is weighed after the fact instead. The copy's
own size stays authoritative for the handle.
(3) Both `react-native` mocks went inert when `324c4093be` dropped `Platform`. Gone,
along with the two-platform loop that had become one code path run twice; the 6b
guard now reads "never calls `requestMediaLibraryPermissionsAsync`", which is what it
was checking.
(4) The hook case no longer claims a permission prompt.
(5) The 150-character comment is wrapped, along with the three other comments over
100 that I had authored in this file and the two platform modules.
Each behavioural pin reds on its own mutation: pushing to `owned` above the throw,
and dropping the pre-copy ceiling check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse what cannot be weighed, and count a Files pick before staging it (OTA phase C, C7.4)
(a) The pre-copy weigh is now a mechanism rather than a docstring, and the branch it
takes is the one the SDK allows. Verified in expo-file-system 55.0.26: a `content:`
uri reads its size through `SAFDocumentFile.length()`, so the common case is weighed
before `bytesSync()` materializes anything. There is no bounded read to fall back on
for the rest — `File.open()`, `readableStream()` and `writableStream()` all reach
`FileSystemPath.javaFile`, which throws `This method cannot be used with content
URIs` outright, leaving `bytesSync()` as the only read a provider uri has and it is
all or nothing. So an item whose provider reports no size, which answers 0 and is
indistinguishable from an empty file, is refused rather than read at an unknown
size. An empty pick was nothing to stage either way.
(b) `getDocumentAsync` takes no selection limit, so the room `pick` hands it was
advisory and a user could return more than the registry holds. Counted before
staging: previously all nine assets were copied into the cache and `mint` refused the
lot, which the red run showed as nine discards. The refusal is
`native_media_handle_cap` and it sweeps nothing, because nothing of this shell's
existed yet.
Three pins, each red on its own mutation: dropping the unweighable refusal, making
the room check a no-op, and taking it off the Files arm.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the last two traces of the library permission prompt from the media tests
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
e6aa90ff36 |
test(mobile): certify the browser pane's golden families and render it in a page (OTA phase C, C6.5) (#21777)
* test(mobile): pin the browser pane's golden families The half pin for C6: 4 families, 15 goldens, every verdict the one C2's rule predicts. Measured per family with vitest `-t` over the full 787-golden corpus, with C1's 103 reproduced golden-for-golden as the control: 6 byte-identical, 9 result-absent-settlement. No composed `c6-page-closure.ts`: a composed table is pinned against a route and the browser is a pane, so C7's route is what composes this with C1's. The derivation census does not wait for that route. `mobileWebAppRoute- Closure` becomes one case of `mobileWebAppModuleClosure`, which takes any entries, so the pane's own closure can be read from the module. Two cases: the pane alone reaches exactly the pinned four, and the pane beside `app/h/_layout` adds exactly those four and no other, with the layout reproducing C1's 22 as the control for the difference. Closure at this base: 48 local modules alone, 34 beyond the layout, 30 under `src/browser` and four through the web siblings. The design said 23, all under `src/browser`; it was measured before C6.2 and C6.3 added those siblings, so the pin carries the re-measured number. `browser.screencast` has no golden at all, so this certifies the input path and says nothing about the frame path. Red first: with `browser.wheel` dropped from the table, both census cases fail naming the missing family; restored, the file's 10 cases pass and the parity suite reports "15 goldens in 4 families, 6 byte-identical". Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the frame budget against the shell's real frame Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's device scale from a skeleton it builds itself, and until now its only check was another skeleton of the same shape in the same file: two copies of one assumption agreeing with each other. This measures the real thing. A frame with CDP's nine metadata fields and a real `Page.screencastFrame` timestamp, encoded by C6.1's `encodeBridgeScreencastFrame` and serialized by the real `BridgeHostSubscriptions`, posted through the host harness: 303 bytes besides the image, against a bound of 516. Held above is not enough on its own — 213 bytes of slack is room for the shell to grow the envelope by a field the page never hears about — so the bound is reconstructed exactly instead. Every byte of that slack is a number this frame prints narrower than a double can; adding those back gives 516 on the nose. The budget cases run a generated noise image at the budgeted scale, not a committed fixture: the worst case is the image JPEG compresses least, and a photograph sits a tenth of the way to it. 901,161 px at 0.545 bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of the 655,360-byte cap. One envelope more and the shell drops it, which is ruling 1 read from the budget's side. Red first, two ways. Drop the metadata widening from the bound and three cases fail, the sharpest being the real shell answering the frame the page thought it could send with zero posts. Add a field to the shell's own envelope and the reconstruction fails at 516 against 548, where the existing suite stays green on all 14 — which is the drift this file exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the measured frame bytes, correcting |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
cef4416115 |
fix(mobile): name the narrow host header's controls and gate the drawer's hardware back on web (OTA phase C, C2.10) (#21729)
* fix(mobile): name the narrow host header's controls The header renders two toolbars and the phone sees the narrow one, whose controls carried neither a role nor a name. A screen reader could not find them, and C2.9's render check could only assert their absence at 390 px. The wide toolbar already names every control from the same state, so the fix is to say the same thing rather than invent wording: filter, sort, group, accounts, tasks and the search toggle take their wide sibling's role and label expression verbatim. The census names a seventh site the plan did not: the Reconnect button in the status bar above both toolbars, which is shared rather than narrow and has no wide sibling. It announces through its Text child today, so it takes the role and the string it already renders. No layout, style, handler or order changed; the diff is accessibility props only. The census parses the file with the TypeScript API and rules that every Pressable carrying an onPress has a button role and a name, and that a control both toolbars render is named the same way in both. It keys the pairing on the handler, because that is what makes two elements the same control, and asserts each shared handler is found exactly twice, so a control deleted from one toolbar cannot leave the naming rule comparing a group of one with itself. Red first, naming all seven sites by path and line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the right drawer arming hardware back on web React Native Web logs "BackHandler is not supported on web and should not be used." and returns an inert subscription, so inside the shell's page every open of this drawer put that line on the console and armed nothing. The gate is the one mounted-bottom-drawer and the file preview already carry, with the same comment stating the degradation: there is no hardware back in a WebView, and the shell owns the one the phone has. The drawer had no render test. This one mocks react-native, the safe area, gesture handler and Reanimated the way the bottom drawer's hand-back test does, and reads the call rather than the console: on iOS and on Android the handler is registered once for 'hardwareBackPress' and released when the drawer hides, and on web it is never reached. The three native cases are the control that keeps the web case honest; they passed before the fix, which is what makes the single red meaningful. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): type the right drawer test's element helper The tests-typecheck ratchet was red on the previous commit: the drawer's props declare `children` as required, so passing it as createElement's third argument left no overload matching. It is a prop here, and the helper answers a ReactElement rather than a return type borrowed from createElement. Test files sit outside `tsc --noEmit`, so only the ratchet sees this; it is the gate that exists because a type-level pin in an unchecked test proves nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the right drawer test in JSX Lint was red on the previous commit and I ran it in the same command as the commit, so it landed: passing `children` as a prop to satisfy the type checker is exactly what react(no-children-prop) refuses. The canonical form settles both, so the test is JSX in a .tsx file and the drawer takes its body as a child again. The StyleSheet mock's generic needs the trailing comma a .tsx file requires. Re-proved in this form: with the web gate removed the web case fails and the three native cases still pass. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): derive the header's naming groups, and read a spread as unknown Round 1, four folds. The drawer's comment claimed the console line was observed inside the shell's page. It was not: the drawer's one caller is the review screen, whose route C4 serves, so no page closure reaches it today. The gate is pre-emptive and now says so in its own words rather than borrowing the bottom drawer's sentence. The naming rule iterated a hand-written list, so it only ever compared the six controls both toolbars render. Giving the two `actions.openFloatingWorkspace` sites different labels left the census green. The groups are derived from the discovered controls now, keyed by the handler text, so any handler this header presses from more than one place is compared and the failure prints both names. The declared list stays as the precondition it always was: each of the six is found exactly twice, which is what keeps the derived rule from holding vacuously over a file with no repeated handler. The scan read `Pressable` only and dropped any control whose `onPress` read as empty text, which is what a spread reads as. It reads `TouchableOpacity` too now, and a spread answers unknown rather than absent: a control whose handler or whose accessibility props arrive through one is kept, fails both rules, and prints `spread` rather than `none`, so it can never be mistaken for a control the scan judged. Red first on all four: the reviewer's disagreeing-label mutation, a spread over the a11y props, a spread over the handler, an unlabelled TouchableOpacity, and a shared control deleted from one toolbar. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a braced-empty label as unnamed, from one shared reader Round 2, two folds. Only a bare `""` or an omitted attribute read as unnamed, so `accessibilityLabel={undefined}`, `{''}` and an empty template all left a control with nothing to announce and the census green. Reproduced on both Tasks sites with each of the three shapes before the fix. The reader unwraps a braced expression now: a string or a no-substitution template answers its own text, and the identifier `undefined` answers empty, so all three read as unnamed. That reader was a near-verbatim copy in both censuses, which is how one of them could have gained this rule and the other kept the hole. It lives in one module under mobile-web-shell now, named for what it reads and typechecked by mobile tsc rather than by the ratchet alone. Both censuses import it and neither changed an assertion; their diffs are the deleted copies and the import. Red first, five mutations: the three empty shapes on both Tasks sites here, and `{undefined}` and `{''}` on the tasks Back, which the page census now catches too and did not before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
6a0200f416 |
test(mobile): certify the files page closure, 28 families and 125 goldens (OTA phase C, C3.2) (#21724)
* test(mobile): certify the files page closure, 28 families and 125 goldens
C3.2. The closure is re-derived at this base from the entries esbuild compiles —
the two `.web.tsx` files — and matches the design exactly: 28 families, 125
goldens, C1's 22 a strict subset, 6 families and 22 goldens added. Explorer
3441 modules / 304 local / 10 under `src/files`, preview 3666 / 330 / 19, union
342 local. No `mobileWeb.*` family appears, which is the tell that the native
switch was not measured.
Two tables at the route seam, as C2.6 splits its own: the explorer's one family
and the preview's five are separate evidence for two routes with separate
grants, and `c3-page-closure.ts` only spreads them over C1's.
C1's 22 families are inherited verbatim, not re-derived. Measured here, C2's
rule disagrees with 13 of those 103 pins — all 7 in `tasks.smart-source-search`,
all 5 in `host-worktree-refresh`, and `worktree-catalog-snapshot`. C5's
docstring says 10; corrected in this commit, comment-only. Every family C3
shares with C2 and with C5 is asserted equal object for object, and the three
committed pin files disagree on nothing.
Class totals: identical 66, result-absent-settlement 47, params-undefined 7,
result-absent-stream-release 3, write-ordinal 2. Pinned beside the per-id walk
because a table built wrong in a self-consistent way passes the walk.
Red-first, both halves: changing one verdict fails the totals and the
inheritance check by name; dropping a golden fails drift, totals and the census
count together.
What 125 certified does not say: `host-worktree-refresh`, inherited from C1, has
no byte-identical golden at all, so its 5 hold a class and not bytes. All six
families C3 adds have at least one. No scenario replays a save twice, so
`files.writeTerminalArtifact` is certified for one round trip and not for
idempotency; and no golden here subscribes, because the domain opens no stream.
The `config/scripts` precondition now derives both files routes and compares
their union against the C1 + C3 tables, with a second case proving each route
reaches a strict part of it — without which the union would pass with one route
contributing nothing.
Also folds pullfrog's open nit: the render check's comment claimed
`toContain('readme.md')` proved the encoded round trip, which a truncated path
would also satisfy; the url assertion beside it is what proves it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): share the pin-source reader and finish C5's 10-to-13 correction
Two pullfrog findings on #21724.
`c5-page-closure.test.ts` still said the rule disagrees on 10 of the 103; only
`c5-page-closure.ts` moved in the last commit, so C5's two files disagreed with
each other and with C2's and C3's. The sentence now states 13 with the same
breakdown the other three carry. No assertion depended on the number. While
there, the comment records why C5 compares against the imported object rather
than the committed text and is still sound: it inlines its families instead of
spreading C1's, so there is no spread for an edited entry to launder through.
`pinsFromSource` was byte-for-byte identical in C2's and C3's suites — checked,
not assumed — and is now one module both import. It sits beside
`page-closure.ts` rather than inside it, named for what it reads: the module it
would have joined holds pure table arithmetic and this one reaches the file
system. The C2 suite's cases and assertions are unchanged; its diff is the
deletion of the copy and one import.
The shared reader keeps its teeth: making the wrapped-entry capture unmatchable
reds the inheritance check in both composed suites, which is the defect the
comment describes — three `result-absent-stream-release` pins once went missing
that way with an empty mismatch list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name which leg of the path round trip each assertion proves
Round-1 nits 3 and 4. A second commit rather than a fold into `dc1de6e728`:
that one is already committed, and amending is off the table.
The render check's comment claimed the url assertion was the proof of the round
trip. It is the outbound leg only — what the page encoded into its own history —
and a screen that mis-decoded the middle of the path satisfies it and the title
assertion both. The comment says that now, and points at where the inbound leg
is proved instead: `mobile-file-path-route-encoding.test.ts`, which reads each
hazard shape back out of the href, and `mobile-file-preview-route.test.ts`,
which drives the normalizer the screen reads its params through. Both files
checked to exist, and read, before being cited.
The 10-to-13 edit left a 127-character line in a file that wraps at 100;
reflowed. It was the only over-length line the C3.2 commits introduced — the
others in both files predate this branch, and `oxfmt` accepts them because it
does not reflow comments.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the inheritance case is blind to, and finish the reflow
Round-1 item 3, plus a correction to my own claim.
The "inherits C1's families whole" case reads C1's committed text, which makes
it independent of the spread but not of C1's file: edit a verdict there and both
sides of the comparison move together. The docstring now says that, says what it
does catch — a C3 half redeclaring an inherited family, which the spread would
otherwise take silently from the last table — and names where the C1-file edit
is caught instead.
Both measured here rather than taken from the review. Flipping
`settings-repo-metadata-icons` in C1's file leaves this case green and reds
seven others: the class totals and the exclusion counts in C2's suite and this
one, both cross-series agreements, and C5's own inheritance case, which compares
against an independent literal rather than a spread. Redeclaring
`settings.repo-metadata` in the preview half reds this case along with five
more.
I also said last round that the 127-character line was the only over-length line
these commits introduced. That was wrong: I checked the two files in that commit
rather than the branch. Six lines over the 100-column wrap came in across four
files, including two I had just written in `c3-page-closure.test.ts`. All six are
reflowed, and the check is now over every line the branch adds rather than over
the files I happened to touch last.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state both redeclaration shapes, or neither count
Round 2's low finding. The docstring said redeclaring `settings.repo-metadata`
in the preview half reds this case "along with five more". The reviewer is right
that the number is shape-dependent, and right about both figures.
Re-measured, no truncation this time: one golden under the family's name reds
seven, because it also shrinks the census to 114 and leaves that family with no
byte-identical golden; the family copied verbatim with a single verdict flipped
reds five, the census unmoved at 125. The docstring states both with the reason
they differ, and says plainly that the count is not the claim — the load-bearing
half is that the spread takes the last table's entry and this case is what sees
it, which holds in both shapes.
The six came from reading a `head -6` of the failure list as the whole of it.
That is the same mistake in miniature as the one this file's own comments warn
about: an empty-looking result that was only a truncated one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
335d111265 |
fix(mobile): name the Back control in every page-served screen (OTA phase C, C2.8) (#21715)
* fix(mobile): name the Back control in every page-served screen The C2.7 device proof could only reach the tasks Back by tapping the native control's coordinates: inside the shell there is no native chrome behind it, so a bare Pressable is absent from the accessibility tree and a screen reader has nothing to announce. The agent-history panel's Back carries a label but no role; the tasks one carries neither. Both gain `accessibilityRole="button"`, and the tasks one the app's own wording, `accessibilityLabel="Back"` — the spelling its thirteen siblings use, including the agent-history panel this screen sits beside. The host screen's Back already carried both and did not move. The new census is a rule rather than a count: it finds every Back affordance under the screen tree each page route serves — a Pressable rendering a ChevronLeft that either carries the back-button style or calls back — and requires a button role and a Back-shaped label on each. Its coverage assertion reads MOBILE_WEB_PAGE_ROUTES, so a route registered without a screen entry fails here, the way the flag census fails for a switched route. Red first on both offenders by name. Two tasks parity hashes move, and only the two a JSX prop must move: `semantics` 3,272 -> 3,274 for the widened host signature and the two new runtime strings, and the render-token stream 35,195 -> 35,203 for the eight tokens the two attributes are. Hooks, statements, declarations and styles hold, which is the evidence that nothing executable changed. Recorded in the test's comment. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): judge a Back control by what it does, and name the files pair Round 1 found the census discriminating on the ChevronLeft glyph, which separates nothing: dismisses sit in the same header slot with the same back-button style, so the rule claimed a dismiss and told it to be called Back, while the explorer's Close escaped only by drawing an X. The predicate is now the behaviour. A Back control is a Pressable whose press handler reaches a back call — a `back()`, `goBack()` or `onBack()` call, a handler named for one, or a local handler whose declaration makes one — or whose label already says Back. A control matching neither is outside the rule whatever it renders. The reviewer's mutation, a dismiss with the back-button style wrapping a ChevronLeft behind `onDismiss`, is claimed by the old predicate and passed over by this one, which is the whole of the difference. A spread now reads as unknown rather than absent, so a Pressable that hides these props behind one is a Back control that fails both rules instead of a control the scan never sees. No Pressable in the five trees spreads today, so the branch is inert and load-bearing. C3.1 registered the files explorer and preview, so the coverage assertion went red on its merge, as designed. Both screens join PAGE_SERVED_SCREENS and both Back controls gain `accessibilityRole="button"`; they already carried `Back to session` and `Back to files`. The tasks Back gains the `hitSlop={8}` its four siblings carry, so its touch target is no longer the glyph alone. Nine mutations: the dismiss stays out; a dropped role on each of the five controls and a dropped label on the tasks one go red by name; a spread fails both rules; a dropped table entry fails coverage; and dropping the host screen's label — its only detector, since `actions.leaveHost` is opaque — empties that tree and fails the presence assertion rather than passing in silence. Two parity hashes move for the one added prop and no count does: `semantics` holds at 3,274 lines with one changed, the host signature gaining `hitSlop`, and the render-token stream gains that attribute's four tokens, 35,203 -> 35,207. Recorded in the test's comment. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): assert a Back per page route, not per screen directory Round 2 found the presence assertion reading directories while two routes share `src/files`, so the explorer's Back answered for the preview's as well: rewriting the preview's control to `onPress= {handleClose}` with `accessibilityLabel="Close"` left that route with no accessible Back and the suite green. Reproduced exactly, then made red by this change, which names the route and the module it failed in. Each PAGE_SERVED_SCREENS entry now names the module that renders its route's Back, and presence is asserted per entry. The rules still read whole trees, so a Back added beside a screen is ruled as well as the screen's own; the tree is derived from the module's directory rather than declared twice. The failure prints the offending list instead of comparing two values a reader cannot tell apart. The doc comment claimed the preview was carried by the label half of the predicate. It is not: `requestBack` is named for what it does, so the preview is found behaviourally like the explorer, the tasks screen and the agent-history panel. Dropping the label from each of those three leaves it detected and failing the naming rule, which is the evidence. Only the host screen depends on the label half, because `actions.leaveHost` is a member access this rule does not follow. The admitted residual gap stays admitted and the predicate is not widened for it: a second Back control in a screen that already has one, with an opaque handler and a label that does not say Back, is still outside what this rule sees. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ac024d4f05 |
feat(mobile): serve the files explorer and preview from the page (OTA phase C, C3.1) (#21710)
* refactor(mobile): take the files screens' router from the handoff seam Inside the shell's page a screen is one document standing in for one screen, so a target the page does not render has to be handed back to the app that does. `useRouteHandoff` is where that decision lives, and its web sibling is the only thing that makes it; both files screens held expo-router's own `useRouter`, so on the web the explorer's Back and the preview's Back would post nothing and a target outside the page would paint Unmatched over the page it is on. Natively this is the same object — `route-handoff.ts` is `useRouter()` — so no behaviour moves here, and `back()` stays expo-router's until the navigate-back verb lands and the seam starts wrapping it. A census rather than a behaviour test: neither screen's own tests can see the difference, because a push that is never handed off still works for a target inside the page. It walks this directory, refuses a value import of expo-router, and names the two screens that must hold a router so a walk that found nothing fails instead of passing empty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the shell stand in for the two files routes Both route files take the index.tsx shape — flag, MobileWebShellScreen, native screen as fallback — and both gain the `.web.tsx` sibling that shape forces. Inert until the manifest lists these routes: the shell answers `native-route` for a route the bundle does not name, which is what `fallback` renders, and the flag is `__DEV__`-only besides. Listing them waits on C2.3 and C2.5. The sibling is not a precaution. The manifest defers every route behind `import()`, so a native-only route module is invisible until the page opens that route; the render check now opens both and, without the siblings, painted `expo-modules-core.requireNativeViewManager is not available on web` instead of the screen. That is also why the two cases render the route rather than asserting a file exists. The file path never becomes a path segment: only `hostId` and `worktreeId` are spelled into the pathname, encoded, and everything else — `relativePath`, `absolutePath`, `cwd`, `pathText` — is a param, which is how a `/`, a space or a `..` stays out of the segment vocabulary the bridge holds a route to. The preview render case proves the round trip on `docs/my notes/readme.md`. `mobileFilePreviewShellParams` drops a param the normalizer left `undefined` rather than sending it empty, because the page reads these back through useLocalSearchParams where `line: ''` and no `line` are different screens. Its test drives the normalizer rather than a hand-written literal: the literal omits the key entirely, so it held with the filter removed. The preview case also records what React Native Web says out loud — BackHandler is inert on web, so Android back inside the page skips the unsaved-draft prompt. Named in the assertion rather than filtered out, so closing it is a change to that line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ask about an unsaved draft in the screen, not through Alert React Native Web's `Alert` is `static alert() {}`. Inside the shell's page that made Back with an unsaved terminal-artifact draft a button that did nothing at all: no prompt, because the dialog is a no-op, and no navigation either, because the code took the branch that shows one. Silently, with nothing on the console. The prompt is now a row under the header. Not `ConfirmModal`, which every other confirm here uses: that is a `BottomDrawer`, and C1.9 has Reanimated's animated styles never reaching the DOM node on WKWebView, so on iOS in the page the drawer parks off-screen and Back would be dead a second way. This paints the same on every platform with no animation behind it. Hardware back is registered natively only. React Native Web's `BackHandler.addEventListener` logs "BackHandler is not supported on web and should not be used." and hands back an inert subscription, so the guard never armed there regardless; the render check asserted that console error on main and now asserts none. The degradation is real and stated rather than hidden: Android back inside the page pops the native stack without asking, and the page's own Back control is where the question lives. The decision moved to a hook so it is testable without a screen: the prompt also drops itself when the draft it was about is saved or reverted, which is a state `Alert` had no way to be in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep expo-haptics' DOM shim out of the page expo-haptics has a web build, and with no `navigator.vibrate` — iOS Safari, which is the WebView the page runs in — it fakes a haptic by appending a hidden `<label><input type="checkbox" switch>` to `document.head`, clicking it, and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to exactly that stray click, and the file explorer calls `triggerSelection` on every row tap, so C3 is the first domain to fire it per tap rather than per long press. `haptics.web.ts` answers the same five names with nothing. A phone holding the page is a phone whose native app is right there with the real haptics, and a missing tap feedback is worth less than a tap that does not register. The test reads the shipped bytes rather than the import, because that is the claim: with the override removed the bundle carries `ariaHidden` and `pointer: coarse`; with it, neither, nor the `setAttribute("switch"` that does the clicking. Not `navigator.vibrate` — react-native-web's own Vibration export calls that and touches no DOM until something invokes it, which cost this test one wrong red before it was narrowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the files routes native when the page could not be given one A file path is a param, so `/`, spaces and `..` all cross safely — but `BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 and a Windows long path is not bounded by anything the user cannot exceed. The symptom is not the blank document the design predicted, and the correction matters: `bridge-host.ts` already parses the route against the page's own schema and drops it to `null` when it fails, so `init` arrives naming no screen and the page paints "Update Orca to open this workspace" — a wrong message about a fine app, over a native screen that works. Deciding before the switch instead leaves the route native, which is where every route starts. The schema is the predicate rather than a copy of its bounds, so the rule cannot drift from the half that matters, which is the half the page reads. The same call also refuses a `worktreeId` the segment rule will not route: `..` survives `encodeURIComponent`, which is the C1.8 class. The tests assert the schema really refuses each input before asserting the guard does, so neither case can pass by being impossible. This belongs in the shell beside the schema; it is in the files domain while the contract files are the C2 lane's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin what keeps a file path out of the route vocabulary Seven shapes, one case each rather than a representative: a plain path, a space, a dot segment, an already-encoded slash, a fragment, non-ASCII, and an absolute path. Each is checked in the two directions a path travels — the href the shell writes into the page's history, and the href the page would hand back — for both the pattern accepting it and the path coming back out of the query unchanged. The counterfactual is in the file: the same paths spelled as a segment are refused. Without that, the cases above would hold for a rule that was never doing any work. Mutating `stringifyRouteHref` to join its query by hand instead of through `URLSearchParams` fails three of them. Also fixes two new test files the tests-typecheck ratchet caught: the partial `react-native` mock needs a typed `addEventListener`, `act` will not take a callback that returns a value, and `findAllByType('Pressable')` does not typecheck against `ElementType` — the neighbouring files that do it are grandfathered, so the tag comparison goes through a helper instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): derive the discard prompt instead of clearing it in an effect Both changed-code gate findings, which the lane had not run until the last commit. React Doctor is right: the effect that cleared the prompt when the draft went away adjusted state after a prop changed, so a save landing while the prompt was up painted one frame still offering to discard nothing. The prompt is now `asking && hasUnsavedDraft`, which cannot be stale by construction, and the test that covers it passes unchanged. The hoisted mock's `as` on a string literal is gone too: the literal narrows on its own and the tests reassign it, so the holder is annotated instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): add the files routes to the hybrid shell flag census The census pins every file that reads `useMobileWebShellEnabled`, because a reader nobody listed is how a dark feature stops being dark. C3's two routes are deliberate entries: each has a native screen behind it as `fallback`, and each is inert until the manifest lists the route. Found by the full mobile suite rather than by the files subset this lane had been running per commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve the files explorer and preview from the page The last C3 commit: both routes join MOBILE_WEB_PAGE_ROUTES, and the shell starts rendering the page for them on a phone with the dev flag on. Grants are not the same for the two, and the difference is the point. Both take `navigate` (Back pops the native stack, and the explorer's rows open the preview beside it) and `storage` (the shared components the host layout renders above them). Only the preview takes `externalLink`: a Markdown preview renders links and `MobileMarkdown` opens them through the platform seam. The explorer does not, and measuring is what says so rather than reading. Every page route reaches `external-link.web.ts` — `/h/[hostId]` and agent-history included, both granted nothing for it — because the protocol wall in the shared host layout imports it. So closure membership is not the oracle for a grant; the question is whether the route's own screens call it, and only the preview's do. `MobileMarkdown` is in the preview closure and absent from the explorer's, which the census now asserts in both directions. Neither route writes a clipboard, so neither takes `native.clipboard.write`; the census pins that as the absence of both `ExpoClipboard.web.js` and the clipboard seam, with the tasks closure as the control that the probe can see one when there is one. The seam predicate moved into a module both censuses import rather than being restated per series: two spellings of one rule drift, and this one is a regex. Red-first: both manifest assertions failed on the new entries before they were updated, and routing `MobileMarkdown` around the seam fails the preview's census while leaving the explorer's passing, which is the asymmetry the grants encode. Closure sizes as the page ships them, extensionless so the `.web.tsx` is what is measured: explorer 3439 modules / 302 local / 10 under src/files, preview 3667 / 331 / 20. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the files route's ids as one value and key the shell on them Two round-1 findings, both reproduced before the fix. A repeated query key reaches `useLocalSearchParams` as an array, and the explorer read `hostId` and `worktreeId` bare. `String(['a','b'])` is `a,b`, so the template built `/h/host-a%2Chost-b/files/wt-1%2Cwt-2` — a single segment the bridge's rule accepts, and the shell would open a page for a host nobody has. Read through `firstParam` now, as the tasks and agent-history switches do. The preview already went through `singleParam` and is unchanged. Neither switch keyed `MobileWebShellScreen`, where `index.tsx`, `tasks.tsx` and agent-history all do. A host captures the grants its session opened with, so a screen reused across a route change keeps authorising frames under the grants of the route the page has left; only a remount drops that bridge. Both are keyed on the route pathname now, with agent-history's reason. The new route test is the agent-history one's shape. It caught both: the array case landed on no route at all, because `name` was an array too and the schema refuses a non-string param value, and the two lifecycle cases saw a prop update where a remount was owed. It also needs agent-history's `lucide-react-native` mock, since `firstParam` lives in the source-control barrel. `name` is now omitted when empty rather than sent as `name=`, matching the two switches beside it: an absent label lets the panel derive its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): confirm a discarded draft with the app's own modal Round-1 findings 3, 4, 5 and the minor one. **ConfirmModal, not the bespoke row.** The row existed because C1.9 had Reanimated's animated styles never reaching the DOM node on WKWebView, which left every BottomDrawer parked off-screen. C1.10 (`b7c06900e2`, an ancestor of this branch) fixed that with a dependency array on the mapper hooks, and the drawer render check now holds it on WebKit as well as Chromium. With the reason gone the row does not stand on its other merits: `Alert.alert` was modal on native before the page existed, and the row quietly changed that for phones too, so the app's own confirm is both the idiom and the closer behaviour. `MobileFilePreviewDiscardPrompt`, its test and its thirty style keys are gone; the hook's state machine and its tests are unchanged. **The encoding test claimed more than it pinned.** Hand-joining the query reds only three of the seven shapes; `docs/readme.md`, `../etc/passwd`, `docs/日本語.md` and `/logs/run.txt` are encoding-neutral in the query, whose pattern half is `[^#\s]*` and admits a slash, a dot segment and non-ASCII verbatim. Rather than narrow the claim in a comment, the split is now pinned by behaviour: each neutral shape must survive the query unencoded, each load-bearing one must not. Moving `docs/readme.md` between the lists fails it. **The manifest comment named one shared-layout opener and there are two.** The New Workspace source field, which the sidebar renders on a wide layout, opens a URL through the seam as well. Both are the shared layout's and every `/h` route reaches both, `/h/[hostId]` included with no `externalLink`, so the tablet tap is dead on all of them — recorded here as pre-existing rather than fixed, since the grants do not move. **Minor:** the dot-segment case in the guard test now asserts the schema refuses the route before asserting the guard returns null, as the length case does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop every page drawer logging a BackHandler error when it opens Round-2 findings. **The registration belongs to the drawer, and that is where the guard went.** `mounted-bottom-drawer.tsx` armed `hardwareBackPress` whenever a drawer was visible and interactive, with no platform check, so the hook's claim to have dropped that console line held only while its prompt was closed — and every page drawer since C1 has logged it on open. Platform-gated at the drawer now; the hook's comment says so rather than claiming the credit. **Nothing had ever opened a modal in a browser.** The render check next door mounts both files routes and reads what they paint but taps nothing, so `ConfirmModal` inside the page — a BottomDrawer, so Reanimated, a portal and a gesture handler — was unproved. A new render file loads an editable terminal artifact through the harness's scripted reply, edits it, taps the page's Back, and asserts the prompt's title is up and no BackHandler line is on the console. Red first on exactly that line; the prompt itself painted, which is also the first proof on a browser that C1.10's fix carries a real drawer in the page. A second case answers Stay and checks the draft survives. Its own file rather than the render check's, which is at 482 of the 600-line cap; registered in pr.yml. **The encoding rule was stated wrong.** Two rules decide it and neither is about paths: the pattern's query half refuses whitespace and `#`, and `URLSearchParams` is form-urlencoded, so it reinterprets `&`, `+` and a valid `%XX`. `a+b.ts` reads back `a b.ts` and `a&b.ts` reads back `a`, so both are load-bearing; `a=b.ts` and `a%b.ts` are not, because only the first `=` splits the pair and a lone `%` begins no escape. A newline joins the load-bearing list as the refused shape rather than the altered one. **The web sibling read its params bare** where the native one uses `firstParam`. Not reachable — the page only arrives through `init.route`, whose params are already `Record<string, string>` — but the two files are meant to be one screen. The preview keys on the pathname alone, and the comment now says why that is enough: every caller in this tree pushes. Closures after this: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. The explorer grew two modules because its web sibling now reaches `firstParam`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the explorer the grants the preview needs, and key on the route Bot findings, one of them a real gap. **Pullfrog is right, and my grant oracle was half a rule.** Grants resolve once, from the route the shell opened: `grantsForRoute` reads `session.routePathname` and `init.grants.native` carries the answer for that session. The explorer's rows push to the preview, and because the preview is a page route that push stays inside the same document — no second `init`. So a preview opened that way runs under the explorer's grants, and a Markdown link in it was refused by `notifyExternalLink` with nothing on screen to say why. "Does the route's own screen call it" was right for a route's own screens and wrong for the routes it reaches in-page, so the explorer now declares `externalLink` as a transitive grant, with the comment saying that rather than claiming it opens links. The census pins the pair as a superset; removing the grant reds it. **The seam regexes matched one quote style.** A double-quoted `react-native` specifier walked past both censuses unseen. Both styles now, with the predicate tested directly for the first time. **The discard request outlived its draft.** `asking` stayed set after a save or a revert, so the next edit re-showed the prompt with no Back request behind it. The request is now dropped when the draft it was about goes, adjusted during render rather than in an effect — the shape React Doctor named in the round-1 fold. Red first: save with the prompt up, edit again, prompt is back. **CodeRabbit's keying comment is a correctness point, not the question I answered.** The page learns its route exactly once, out of `init`, so a same-path param change — another file in the same worktree — left the shell mounted and the page still showing the file it was opened on. My comment claimed "the screen reloads the preview from the param either way", which is true only with the shell absent. Both switches key on the whole route now, params included; two tests cover the same-path case and both red on a pathname-only key. `build-mobile-web-app-bundle.test.mjs` hit 601 of its 600-line cap on the way, so the two manifest assertions now share one expected list instead of repeating it. Closures unchanged: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the seam test import the module it is testing Round 3. **The blocker is mine and the reviewer's diagnosis is exact.** The seam predicate test imported an absolute path into this lane's worktree. On CI that module does not exist and it takes the whole `config/scripts` suite down; here it resolved to the same file by accident, so the test was green against a tree rather than against the checkout — which is why reverting the double-quote fix left it passing and the predicate untested. Relative now, and proved: reverting the fix in place reds both double-quoted cases, which is the first time this test has failed for the right reason. Every file this PR touches is grepped for `/Users/` and `orca-lanes`; none carries a path. **Three comments outlived the grant change.** The two lists became equal when the explorer took `externalLink`, so "longer than the explorer's" and "declared with different grants" were both false. Corrected to what is actually true: the lists are equal and the reasons are not — the preview has its own consumer in `MobileMarkdown`, the explorer has none and declares the grant because its rows push to the preview in-page. **The duplicated serializer is pinned rather than imported.** `shellRouteHref` lives in `page-bootstrap.ts` beside the page's RPC client and its document channel, so a native route file importing it would pull both into the app. The copy stays, and a test asserts the two agree on three routes; dropping the empty-search branch reds it. **Recorded, not fixed:** the sidebar `HostScreen` pushes to `/h/<id>/tasks` through the handoff, which is local, so on a tablet the tasks page runs without `native.clipboard.write` from any page route and its copy actions refuse silently. Pre-existing since C2.1 for the worktree list and agent history. Named in the explorer's manifest comment as the known remaining hop, with the fix being a handoff rule in its own PR. The equality pin needed `it.each<BridgeInitRoute>`: the inferred table is a union whose members carry `?: undefined`, which the ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
76063e7ab1 |
test(mobile): certify the tasks page closure, 70 families and 266 goldens (OTA phase C, C2.6) (#21712)
* test(mobile): read a page closure's run totals through one reader The C5 gate counted the run's classes inline. C2 needs the same count over its own closure, and two spellings of "what the run tallied" can disagree while both stay green, so the loop moves next to `pageClosureTotals` where the table-side count already lives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): certify the tasks page closure, 70 families and 266 goldens C2 moves the tasks screen to the web, so the goldens recorded at a call site inside `app/h/[hostId]/tasks.web.tsx` and `app/h/_layout.tsx` are the ones whose divergence would be this domain's. Each is pinned by id: the suite's own counts run over 787, where one of the other 521 can pay for a closure golden that stopped replaying. C1's 22 families are inherited verbatim rather than re-derived — C2's rule disagrees with them on 10 of the 103 — and the rule decides only the 48 this domain adds. The pin is split at the domain's seam, one work item opened versus choosing which to open, because the table is 409 lines of data and `max-lines` is not a thing to disable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): correct the C2 pin's inheritance count and census scope Two comments overstated what was measured. The rule disagrees with 13 of C1's 103 inherited pins, not 10 — the 10 was copied from C5's file, which carries the same error over the same 22 families — and the breakdown is now named so the number can be re-derived rather than trusted. The census reads the committed table and does not re-derive the closure, so a golden arriving in a pinned family is caught while a new family entering the closure is not. That was true and unsaid, which is the worse of the two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: derive the tasks page closure's family set instead of trusting the pins Round 2 folds, three. The pin tables walk the families they already hold, so a scenario recorded at a call site the route already imports lands in a family nobody pinned and every assertion stays green. `mobileWebAppRouteClosure` runs in a quarter second and `config/scripts` already imports it, so the derivation is now a test: the family set the closure reaches must equal the union of the three committed tables. C2's inheritance check read the object its own table spreads, which cannot disagree with itself; it now reads C1's file as text. What that does and does not hold is written down, because a verdict edited inside `c1-page-closure.ts` is green there either way — C2 inherits whatever C1 commits. The gate's C1 block gains the run-totals assertion C5 and C2 already had, which is the check that edit does fail. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: run the page-closure family check in the job that installs mobile deps Its closure half asks `mobileWebAppDependenciesPresent()` first, so outside the `mobile_web_app` job it skips itself and the precondition it exists to be never runs. That job sets `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED`, which turns the same question into a failure. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b6e8b1a7b2 |
feat(mobile): serve the tasks screen from the page, with its seams (OTA phase C, C2.1 + C2.5) (#21694)
* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)
`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.
Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.
The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)
Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.
Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.
The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.
No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)
The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.
Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.
The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.
The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.
A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)
The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.
A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.
Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.
Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)
The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.
One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.
No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)
`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`react-native-webview` is a native component with no browser counterpart:
importing it runs a codegen lookup that throws, and the route manifest imports
every route, so one such import takes the whole page down rather than one
diagram.
The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.
Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.
Registered in the overrides, whose gate fails on an unlisted `.web.*` file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)
The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.
The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.
The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.
The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.
The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)
`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.
Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.
The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)
Four from round 1.
The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.
The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.
`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.
`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.
No parity hash moved: none of these files is in the refactor-parity source
set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(config): use endsWith for the clipboard module check
The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)
Five from round 2, two of them real.
The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.
The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.
Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
3ad6b7e46e |
chore(mobile): repin the recording corpus to main's tip after #21674 (#21690)
#21674 re-recorded the corpus with `baseline` set to its own branch commit `1006ad670a`, which the squash merge left unreachable from main. The pin guard on the push to main fails, and so does the pin job on every open pull request, since the merge ref cannot reach that commit either. Repin to main's tip `e8a7be4ce2` and re-record from that tree. Every golden's body is byte-identical to the one #21674 recorded; only the two header lines move: `baseline` (788 files) and `lockfileSha256` (778 files), the latter because #21652 changed `mobile/pnpm-lock.yaml` after that branch recorded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e7da72c3d7 |
fix(omp): attach desktop and mobile images through file mentions
Merge fully verified: desktop/mobile focused suites, node and mobile typechecks, changed-code quality, hosted RPC recording pin, package checks, all test shards, and verify pass. This fixes #20389 across composer, drop, picker, and mobile clipboard-accessory paths. |
||
|
|
ae9c06c941 |
feat(omp): discover and switch native-chat models on desktop and mobile (#20612)
* fix(omp): discover and switch native-chat models Report the running OMP provider/model and discover available choices on the execution host for desktop and mobile. Register an extension command to switch through the OMP API because its TUI does not accept /model args. Advertise that command in status so older hosts remain read-only. Addresses the OMP portion of #17603; Pi chat enablement remains separate. Model reporting begins on lifecycle activity; no startup status is invented. Co-authored-by: SudoAI-DEV <220139811+SudoAI-DEV@users.noreply.github.com> * refactor(omp): check generated model metadata types * test(omp): verify model picker command and reported selection * test(omp): add repeatable real model-switch runtime proof * test(omp): require model capability delivery in runtime smoke * fix(mobile): decode OMP model discovery through RPC operations * fix(omp): preserve exact reported model selectors * fix(omp): preserve generated extension syntax after rebase * fix(omp): merge generated harness UI context types * test(omp): model switching keeps one session manager * test(omp): include transcript path in model status proof * test(omp): avoid renderer error-type union * test(omp): keep renderer test type explicit --------- Co-authored-by: SudoAI-DEV <220139811+SudoAI-DEV@users.noreply.github.com> |
||
|
|
c22c442fdb |
feat(mobile): answer native verbs on the shell, clipboard first (OTA phase C, C2.4) (#21623)
* feat(mobile): declare the native verb table and advertise it (OTA phase C, C2.4) The contract half of the shell-answered request seam: the `native.` prefix, a typed table with params and result schemas per verb, and the two clipboard verbs. `MOBILE_WEB_SHELL_GRANTS` spreads the table's own name tuple rather than restating it, so a verb cannot be advertised without a row and a row cannot exist unadvertised — the table is `Record<BridgeNativeVerb, …>`, so a missing row does not compile, and the suite holds the other direction. Verb names go in the flat grant list on purpose: a route may declare one, and a shell that lacks it keeps that route native rather than walling it. The mime shape admits `image` because a later build will serve one; this one refuses it, and the reason will say out of scope rather than unsupported, since `expo-clipboard` implements the image calls. No frame kind is added and no protocol version moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): answer native verbs on the shell and fence them from the desktop (OTA phase C, C2.4) The host half of the seam. `forward()` is the one place a request reaches the client, so the `native.` check sits there and nothing about ids, caps, settlement or cancel moves: a native request takes a pending slot and settles on the same frames as a forwarded one. `readBridgeNativeVerbCall` is the whole decision, separate from the host so the `ungranted` arm can be exercised at all — every page is offered every verb this build implements, so through a real host that arm is unreachable today and is the point of the check once a grant is per-route. Refusals carry `native_verb_refused`, which the desktop's vocabulary does not contain: an unlisted method comes back from `MOBILE_RPC_METHOD_ALLOWLIST` as `forbidden`, so reusing that would make a leaked fence read as an ordinary scope refusal. Every case in the host suite reads `client.requests` for the same reason. `_meta` is omitted from host-authored replies per the ruling, which required making it optional on `RpcSuccess`/`RpcFailure`: the type required a field the wire never has. `isRpcResponse` does not read it, `runtime-rpc-envelope` already makes it optional on a failure, and nothing in this app reads it — every occurrence is a fixture writing one. Zero other type errors resulted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the harness verb-type import and drop an unused one Two leftovers from threading the native reply type through and then removing it: the host harness lost its `BridgeNativeVerb` import, and the request module kept a type import nothing uses. `tsc` and oxlint both failed on the previous commit; this is the follow-up rather than an amend. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): typecheck the native verb suites and drop the dead reply type Three leftovers the ratchet caught, none visible to `tsc -p tsconfig.json`, which excludes test files: - the fence suite read `frame.payload` off the whole `reply` union, and a chunked reply has no `payload`; it narrows on the field now - the bridge hook's own suite builds its caller options inline and had no `serveNativeVerb` - `BridgeHostAuthoredReply` became unused once `_meta` was optional, and an exported type nothing reads is the pattern round 2 of C2.3 flagged; the statement it carried already lives in the verb table's header Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the page a typed surface for the native verbs (OTA phase C, C2.4) `useNativeVerbs` is the page's side, typed from the same table the host serves, so a verb cannot be called with params the shell will refuse. Each call goes out as an ordinary `request` and settles on the ordinary frames; the method name is the whole difference. A verb the shell did not grant is refused before a frame is sent, because a rejection after a round trip and one that never left look identical to an `await` and only the first costs an in-flight slot — `granted` is exposed so a caller can pick its own fallback instead. Results are parsed rather than trusted: the shell is a different build than the page, and a result shape that moved should fail at the seam rather than halfway through a screen reading a field that is not there. No call site uses it yet; the two `Clipboard.setStringAsync` sites are the consumer PR's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send native verbs from the module that owns the request port (OTA phase C, C2.4) `use-native-verbs.ts` called `client.sendRequest` directly, which the unvalidated-request-port boundary refuses: new code must send through an `RpcOperation`, and nothing may be added to the inventory. An `RpcOperation` is not available to this seam. Its `method` is typed `RpcMethodName`, which is `keyof typeof RPC_PARAMS_BY_METHOD` from the desktop's generated params catalog. Putting `native.clipboard.read` there would declare that the desktop serves a method the whole fence exists to keep off it. So the send moves into `bridge-rpc-client.ts`, already listed as an owner of the port — a module that implements the port rather than a call site picking its own method and acceptance. `callNativeVerb` rides the same frame, id space and in-flight cap as any request, and the page surface stays a thin typed wrapper that reaches no raw port. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fence native methods on subscribe, not only on request (OTA phase C, C2.4) The fence sat in `forward()`, which is the one place a *request* reaches the client. A `subscribe` reaches the same client by another door: a frame naming `native.clipboard.read` opened a real stream on the desktop, and because `client.requests` stayed empty the whole suite read as green over it. Refused in `handleSubscribe` before the id is claimed, under the same `native_verb_refused` code, so nothing about the frame reaches the desktop or occupies a slot. Cancel needs no arm of its own: it can only settle an id that was admitted, and none is. The oracle is widened with it. Every case now reads the client's streams as well as its requests, because the old one could not see this at all — an absence that only ever looked at half the boundary. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a native verb's answer to the result it declares (OTA phase C, C2.4) The table names a result schema per verb and the host never applied it, so a handler could answer `{ nonsense: 1 }` and the page's own parse would be the first to notice — halfway through a screen, not at the seam. Validated on the host and refused by name on a mismatch, which is what makes the table's claim true on the side that serves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the native verb member from being a raw port (OTA phase C, C2.4) `callNativeVerb(verb: string, params: unknown)` took any method, so `callNativeVerb('worktree.list', …)` reached the desktop through the real pair — a raw request port in the one module allowed to hold one, and invisible to the inventory, whose scan counts `.sendRequest` shapes and not a bare call inside the owner. The parameter is typed `BridgeNativeVerb` now, which is the fence for every caller the compiler can see, and the prefix is checked at runtime for one that reached the member through a widened type. The compile-time half is pinned by a `@ts-expect-error` the tests-typecheck ratchet holds: widening the parameter back makes that directive unused and fails there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give every native verb refusal one typed error at the page (OTA phase C, C2.4) Only the seam's own refusal carried `native_verb_refused`. A handler that declined and a reply too large to send arrived as other categories with no code at all, and the hook rethrew a bare `Error(message)` — so a caller telling an out-of-scope mime from an unsendable clipboard had to read message text, and those want different handling. Three changes, one shape. The host re-raises a handler's failure under the seam's code, keeping the handler's message because that is what says why. `BridgeReplyUndeliverableError` carries its frame refusal as a code, so `reply-too-large` survives to the page. The hook throws `NativeVerbError` with a `reason` read off the code `reconstructBridgeError` already copies onto the rejection, plus `ungranted` for the arm this side decides. Removes the unreachable `ok: false` branch from the hook with it. The narrowing it was doing moves into the client member, which now promises a success or a rejection and nothing else. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): test the in-flight cap and cancel, not the duplicate-id branch (OTA phase C, C2.4) The case named for the cap sent the same id twice, so what it exercised was the already-in-flight check. It never held a second slot and would have passed against a seam that took none. It now fills the cap with distinct ids against a handler that never settles, and asserts the one over it is refused with the cap's own message. A cancel case goes with it: a native request cancelled before its handler settles posts nothing afterwards, the way a forwarded one does not answer an exchange the page has moved on from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the native verb member's type with a directive, not a cast The case proving a desktop method cannot go through `callNativeVerb` reached the runtime guard with `as never`, which the casting gate refuses — and a cast is the wrong tool anyway: it asserts past the very type the case exists to pin. `@ts-expect-error` instead, which the tests-typecheck ratchet holds: widening the parameter back to `string` makes the directive unused and fails there. The call still runs, so the runtime guard is exercised too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): parse the shell's error code instead of reaching for it (OTA phase C, C2.4) The anti-slop audit refuses `Reflect.get`: dynamic input is parsed into a named shape before it is read. `code` is not a property of `Error` — it is whatever `reconstructBridgeError` copied onto the rejection from the capture — so a schema is the honest reader here, and it says what this takes without asserting the rest away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the id collision before the native fence on subscribe (OTA phase C, C2.4) The fence ran before the already-in-flight check, so a `subscribe` naming a `native.` method under a live request's id settled that request with the fence's message. The page lost the request either way — the collision class predates this PR — but it was told the wrong cause, which is the difference between a bug it can see and one it cannot. Collision first. The fence still runs before any slot is taken, so nothing about the frame reaches the desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): parse a native verb result once, inside the catch (OTA phase C, C2.4) The result was parsed twice: by the send path against the table's schema, and again at each caller against the concrete one. The second parse was dead, and it sat outside `call`'s catch, so a shell answering a shape the page did not expect would have escaped as a bare `ZodError` — the one shape this surface promises not to throw. `call` takes the verb's result schema and parses once, inside the catch, so every failure leaves as a `NativeVerbError`. The params parse at the callers goes with it; the host validates params and the page builds them typed. Also moves the comment block documenting `onExternalLink` back above it, which `serveNativeVerb` had landed in front of. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give every native verb refusal its own code, and keep handler words on the device (OTA phase C, C2.4) Two findings that had to land together. Six faults all arrived as `native_verb_refused` and differed only in message text, which the hook's own comment said nobody may switch on. And a handler's message crossed verbatim: a clipboard read that failed after reading is free to put what it read in its error, and the error frame is the only path out of this seam that is not a declared result. So each fault gets a code — unknown verb, ungranted, bad params, wrong result, out of scope, handler failure, native-on-subscribe — and the three paths that reached the page uncoded get one too: the in-flight cap, a non-native method through a widened member, and host disposal. `reason` is now drawn from a declared list with no `unknown` arm, asserted at the hook. A handler's code crosses and its message does not; the shell logs the real one. The out-of-scope mime stays distinguishable because the code carries it, not the text. `bridge-host.ts` crossed the line cap with this, so the serving half moves to `bridge-host-native-verbs.ts` — read the call, serve it, hold the answer to what the verb declares — leaving the host the frames around it. No cap was disabled or raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse unknown verb params, and floor an unknown code (OTA phase C, C2.4) Two the bots caught, both about a shape one side does not know. `z.object` strips unknown keys, so `{ mime, value, unexpected }` dispatched as if the extra key had not been sent — and the page and the shell are separate builds, so a param the shell silently ignores is the shape of a verb that changed underneath a page. `z.strictObject` on the verb params and results. And the page passed any code through as `reason`, while its own doc and `NATIVE_VERB_REASONS` promised a closed list; a shell newer than the page would have fallen off the end of a caller's switch. Unrecognised codes floor to `unreported`, `reason` is typed to the list, and the doc says what the list actually is rather than the single code the per-arm ones replaced. The flooring is tested by delivering the frame such a shell would send: this build's host normalises an unknown code before it leaves, so the pair cannot produce one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve no request before the page has asked for a session (OTA phase C, C2.4) `serving` starts true so a page's first frames are not refused for arriving in the same native batch as its `ready`, but nothing checked whether an `init` had ever been sent. So a request from a document this host had told no caps, no grants and no route was forwarded to the desktop, or served as a native verb, while the notify path had refused exactly that since C0. Gated on `initSent`, under the protocol's own `before-ready` name. Streams are left alone: the finding names requests, and gating `subscribe` too is a wider change than it asked for — worth its own decision, since the same hole is there. Fourteen host cases and five hook cases were relying on this: they open a request without ever asking for a session, which no real page does. They take a `ready` now, through a harness option, and the counts that read what the host posted account for the `init` a session opens with. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): grant a page session what its route declared, not what the app can do (OTA phase C, C2.4) `init.grants.native` handed every session the shell's whole capability set, so a route declaring only `navigate` and `storage` was granted `native.clipboard.read` as well. That was harmless while every grant was a navigation or a write the page could make anyway. It stopped being harmless the moment a verb reads something back, which is this PR. The session is now granted the intersection of what this shell implements and what the mounted route declared in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own `fault`. One list: `init` issues it and every grant check — notify and native verb — reads the same one, so what a page is told it may do and what it will be served cannot drift. `MOBILE_WEB_SHELL_GRANTS` and `implementsGrant` are unchanged; the shell's capability set is still the ceiling a route's list is drawn from. User-mediated authorization is not attempted here and goes to C2.7 as an open question. `use-mobile-web-shell-session.ts` crossed the line cap with the extra field, so the three effect workers that touch the network and the disk move to `mobile-web-shell-session-effects.ts`, leaving the hook its reducer and callbacks. No cap was disabled or raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): key the pre-handshake refusal on the session, and apply it to streams (OTA phase C, C2.4) Two rulings, one mechanism. The gate keyed on the host instance, and a host is rebuilt whenever the client under it changes. The page does not know: the session id is the same, so it neither re-handshakes nor hears that the shell was replaced. So a live page's next request was refused, and would have been until reload — a regression, not a safety gain, and not covered by the in-flight settling as delivery-unknown. The host now inherits whether its session already handshook, which the hook records when the page first asks. And the rule is about the session rather than the frame kind, so `subscribe` is gated with `request`: a stream opened before the handshake was the same hole. Fourteen stream cases were exercising a state the protocol forbids — they subscribe without ever asking for a session, which no page does. Every one is about caps, backpressure windows, acks, cancel, idempotency or arity; none was testing anything through the hole itself. They complete the handshake now, and the counts that read what the host posted account for the `init`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): judge a cached fallback by its own routes and grants (OTA phase C, C2.4) The newer manifest is read before the download is attempted, so its `pageRoutes` and `routeGrants` are already on the session when the download fails. Opening the cached generation then mounted an older page under a newer bundle's grants: a cached route that never declared the clipboard would have been granted it by a manifest it is not running. The fallback now derives both from `cached.routes`, and applies that generation's own render eligibility before mounting it — a route only the newer bundle claims is not a route the cached page can serve. This is the Phase D "grants across generations" item arriving early. Only the grant side is fixed here; persisting a generation's grants with the generation itself stays Phase D's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): remount the shell on a route change, so its bridge cannot outlive it (OTA phase C, C2.4) A host captures the grants its session was opened with, and the agent-history route renders `MobileWebShellScreen` with a pathname derived from `worktreeId` and no key. So changing worktree updated the screen in place: the old bridge stayed mounted and kept authorising frames under the grants of the route the page had already left. Keyed on the route now, which makes the change a remount — the old bridge is disposed in the commit, before it can read another frame, and the new session starts with no grants until its own `init`. The worktree-list and embedded- browser routes are keyed on the host id for the same reason; the hazard is the same whenever a dynamic segment moves under a mounted shell. The probe that catches this uses an empty dependency array on purpose: keyed on the pathname it re-fires on a prop update and reads exactly like a remount, which is the one thing it exists to tell apart. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(shared): let a manifest declare a native verb as a route grant (OTA phase C, C2.4) `GRANT_NAME_PATTERN` was dotless, and the contract's own pin asserted a dotted grant is refused. So no manifest the desktop can produce could declare `native.clipboard.write` — and once grants are scoped per route, a verb no route can name is a verb no route is ever granted. Every native verb was unreachable for every route. The grammar now admits the verb shape the table names: `native.` followed by at least two lowercase dotted segments, which is `native.<domain>.<action>`. A plain name wearing a dot is still refused, `native.navigate` included, so the pin keeps its meaning. Wire compatibility, checked rather than assumed: widening what a manifest field may contain is a new optional value reaching readers that shipped before it, and the phone's reader already tolerates one. Its route schema bounds a grant's length and nothing else, deliberately — an unknown name is not a parse failure that would refuse the whole bundle, it is a grant this build does not implement, so `implementsGrant` drops it and the route stays native. Both halves are now tested: an unknown verb leaves its route native and grants nothing, and a known one reaches `init.grants.native`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the session reducer suite by concern before main pushes it over the cap Merged with main the reducer suite reaches 805 lines against a cap of 800 — neither side alone crosses it, which is the case the lane rules warn about. Split at a concern boundary rather than raised: the grant-facing cases (the cached fallback's own routes, and a manifest verb reaching the session grants) move to `mobile-web-shell-session-grants.test.ts`, and the fixtures both suites drive the reducer with move to a shared module beside them, the way the bridge host suites already share a harness. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
dcee2c5df3 | fix(deps): update vulnerable transitive packages (#21652) | ||
|
|
62903b7cf6 |
test(mobile): pin the agent-history page closure golden by golden (OTA phase C, C5.2) (#21649)
* test(mobile): pin the C5 page closure golden by golden (OTA phase C, C5.2)
The suite next door proves the corpus replays byte-identically or in a named
class, as counts over 787 goldens. A count cannot tell this domain's regression
from another domain's improvement, so the 134 recorded at a call site the
agent-history page owns are pinned by id, the way C1's 103 are.
Re-derived at this base rather than taken from the design: 3510 modules, 370
local, 16 under `src/agent-history`, 27 families, 134 goldens. The design measured
3508 / 368 at `889c2b562f`; the two new local modules are `omp-draft-launch.ts`
and `omp-fresh-launch.ts`, which arrived on main, and no family or golden moved.
The `.web.tsx` sibling is the entry: the native file reaches `MobileWebShellScreen`
and pulls the shell in, which adds 46 local modules and the two `mobileWeb.*`
families the C1 docstring excludes by name — so measuring the wrong one of the two
gives the right pin table and a wrong module count.
C1's 22 families are inherited verbatim rather than re-derived. Deriving them from
C2's classification rule disagrees with the committed file on 10 of its 103 pins,
in three ways that are each a true statement read too far: `tasks.smart-source
-search` is a `params-undefined` family and the rule's list of those is the five
C2 adds, not C1's one; `host-worktree-refresh` carries `write-ordinal` and
`result-absent-stream-release` goldens, classes the rule does not model because no
family C2 adds carries one; and "a scenario scripting `{ ok: true }` with no
result" is a property of the scenario a golden derives from, not of its family, so
reading it family-wide moves `worktree-catalog-snapshot`. The rule decides the
five families C5 adds and nothing else.
`c5PageClosureTotals` is pinned beside the per-id walk, and the gate asserts the
run's own class counts over this closure against it. A per-id walk agrees with a
table that is wrong the same way twice: deriving this file with a reader that
skipped `c1-page-closure.ts`'s wrapped entries produced an empty mismatch list, 53
`result-absent-settlement` and 0 `result-absent-stream-release` where the truth is
50 and 3. Only the counts showed it, so only the counts can guard it.
Both halves proved red, not assumed. A verdict changed in the pin fails the gate
as "aivault-history-scan-fulfilled: pinned params-undefined, ran identical"; a
golden dropped from the pin fails three ways — the drift names it, the totals move
72 to 71, and the census count fails.
Tallies untouched: 787 goldens, 396 / 341 / 3 / 6 / 33 / 8, unclassified 0. No
golden moved and nothing under `mobile/rpc-foundation/` was written.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give both page closures one drift, not two copies
Round 1 on #21649. C5's pin file arrived with C1's type alias, observation type,
exclusions walk and drift walk copied across, identical but for the names. The
copies are the problem rather than the duplication: these pins are the instrument
two domains argue with when a golden moves between families they share, and C5's
closure contains C1's 22 families entire — so two spellings of "what drifted" can
disagree about the same goldens while both stay green.
`page-closure.ts` now holds the types and four helpers, each taking the pins as an
argument; `pageClosureTotals` moves there too, since it is a general question and
was C5's only real addition. `readPageClosure` joins them: the gate had grown a
local helper to print one domain's line, which is the same shape one step later.
Both pin files are now their table and nothing else, which is the part that is
per series and meant to be read as a diff.
Output asserted rather than assumed. The gate's two closure lines are byte-
identical before and after — "C1 page closure: 103 goldens in 22 families, 54
byte-identical" and "C5 page closure: 134 goldens in 27 families, 72
byte-identical" — diffed as files, and both pin suites report the same 60 cases
with the same verdicts.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read a golden's family through the corpus's own reader
Round 1 on #21649. Both pin tests parsed a golden file and asserted its shape in
order to read one field, each with a `SAFETY` annotation saying every golden
carries `family`. `readGolden` already answers that question properly: it checks
the format version and the value pool and throws a named diagnostic otherwise,
and returns `family` typed.
Both annotations are gone rather than reworded. C1's is fixed in the same commit
because it is the same line — the cast arrived in C5's file by being copied from
it, and leaving the original would have kept the shape that produced the copy.
The assertion is also stronger than it was. Before, a golden whose format version
had moved would still be read and its `family` compared; now that file fails with
the reader's own message instead of being silently accepted.
Checked that it still discriminates rather than assuming: renaming one family in
the pin fails two cases, the corpus-family read among them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
211821dc17 |
feat(mobile): render agent session history from the desktop's bundle (OTA phase C, C5.1) (#21596)
* fix(mobile): refuse a page target the shell will not take instead of opening it here
`useRouteHandoff`'s web sibling answered two things — handed off, or push it
locally — and fell through to the local router for three different reasons. Only
one of them is a page route. An href the protocol's own pattern drops and a shell
that answered no are the page reaching past what this shell can serve, and the
bundle carries every route under `app/h`, so the fallback does not paint
Unmatched: it mounts `session/[worktreeId]` on React Native Web inside the shell.
The outcome is now tri-state. A target outside `pageRoutes` is never pushed
locally; the page stays where it is and names the reason once per client, which
is the bound the other page-side reporters take.
Proved in the render check against the real bundle: with the double granting no
`navigate`, "Back to hosts" left the host route for `/` and painted Unmatched
before this, and now stays put, posts nothing and reports no page fault.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): encode the host id the worktree row's navigation actions build
Both targets this sheet offers interpolated `hostId` raw — the C1.2 class the
C1.8 stack fixed at the route files and `web.tsx`, at the last two sites that
still had it. `useLocalSearchParams` answers the decoded value, so a deep-linked
id carrying `?`, `#` or whitespace stops being one segment.
It matters more from C5.1 on. Inside the page these targets go through
`useRouteHandoff`, which matches the pathname against the shell's `pageRoutes`
before deciding anything, and the id is the segment the pattern is reading.
The worktree id was already encoded at both sites; this makes the host id match,
and the new test pins all four targets rather than only the one that moved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render agent session history from the desktop's bundle (OTA phase C, C5.1)
The second page route. `agent-history/[worktreeId].tsx` already shipped in the
bundle with its own chunk, so listing it adds nothing to the download and moves
no route count: the switch is `index.tsx`'s, and the shell still decides, because
a bundle naming a grant this app lacks renders the native panel instead.
Its `.web.tsx` sibling is required for `index.web.tsx`'s reason — the native file
reaches OrcaMobileWebShellView, whose requireNativeViewManager runs at import and
takes the whole bundle down in a browser, since the manifest imports every route.
First route with two dynamic segments, so both are encoded. Grants are `navigate`
and `storage`: a resumed session opens the native session screen, the worktree
list now reaches this screen without leaving the page, and `app/h/_layout.tsx`
reads the app's own sidebar width above every page route.
The panel's router becomes `useRouteHandoff`, which is the seam that tells those
two apart: agent history is a page route and is pushed here, the session screen is
not and goes to the shell.
The three writes a resume makes needed no page-side handling and have none. What
they needed was a test that the descriptor's handling survives the extra hop, so
each is run through the bridge and against the same fake directly and the two
verdicts compared: a refused create raises the host's message, and a lost reply or
a shell disposed mid-flight stays delivery-unknown rather than becoming a failure
a user would retry blindly. No golden covers those three.
The flag census grows its first entry since C1.3, which is what it is for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the agent-history Back button to the shell handoff
C5.1 wired this button by swapping the panel's `useRouter` for `useRouteHandoff`;
nothing else was needed, because `RouteHandoff` is the router's own shape and the
seam's web sibling decides `back`. So this commit is the test that would have
caught the wiring being absent, not a fix.
Red before the merge, green after, on the same four cases: on `e897e8123a`, where
`back` was still expo-router's own spread member, 3 failed and 1 passed — the one
that passed is the local-pop case, which is the branch C2.2 did not change. After
the merge brought in C2.2's `back`, all 4 pass. The pre-merge run named the notify
by its literal `'navigate-back'` because the contract constant did not exist yet;
it is the same string `BRIDGE_NAVIGATE_BACK_NOTIFY` holds, so the two runs asked
the same question.
Both module substitutions are the builder's own rather than conveniences: the web
bundle resolves `route-handoff` and `client-context` to their `.web` siblings, so
mocking each to its sibling gives this screen the module graph it has inside the
page. The frames are read off the port pair's lane rather than off a spy, and one
case asserts a frame crossed at all before either absence is read as an answer.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the resume's second write, not only its first
Round 1, finding 1. All three `resumeAiVaultSessionInTerminal` cases settled the
create (`ai-vault-resume-launch.ts:158`), so `terminal.send` had never crossed the
bridge and the half of the resume that types the command into the pane was
uncompared. Three cases now drive both writes: a refused send raises the host's
message, an accepted send reporting `accepted: false` in-band says "Terminal input
is locked", and a send the host takes resolves — the last one being the presence
precondition, since a run that failed at the create would give the same shape of
verdict as one that failed at the send.
Reading `requests[1]` straight after settling the create finds nothing on the
bridged leg: the second write is made only once the first settles, so it is two
more lane round trips away. `nthRequest` waits instead, and says how many it saw
when it gives up, so this cannot pass by proving the opposite of what it says.
The locked reply is `{ send: { accepted: false } }`, not `{ accepted: false }`:
the reader is `reply.send?.accepted !== false` (`review-terminal-reply-schema.ts:65`),
and the flat shape resolves rather than throwing. Written the flat way first, both
legs agreed on "(resolved)", which is the comparison doing its job.
Also finding 1's second half: the file docstring claimed every case runs twice and
differences the verdicts, which was false for the dispose case — a fake RPC client
has no door to shut, so there is no native run to compare against. The docstring
now says so and the case carries the same note.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin what encoding cannot save about a dot-segment id
Round 1, finding 2. The route's docstring listed `/ ? #` and whitespace and the
encoding test covered five ids of that kind, which together implied encoding makes
any id safe. It does not: `encodeURIComponent('..')` is `'..'`, so the pathname
reaches `BRIDGE_ROUTE_PATHNAME_PATTERN` intact, fails the lookahead that stops a
climb out of `/h/` (`bridge-caps.ts:68`, read through `bridge-envelope.ts:117`),
and the shell answers with `reportShellFailure` — a failure screen where the route
would otherwise have rendered the native panel it already has.
Pinned, not fixed, and the docstring now says which. `app/h/[hostId]/index.tsx`
builds its pathname identically and has the same hole, so this series fixing one
of two call sites would leave the shape behind and stop describing it. The new
case asserts both halves — the segment survives encoding unchanged, and the
pattern refuses the pathname — so a later change that starts encoding dots fails
here and has to say which screen it wants instead.
Characterisation, so it was green on the first run rather than red: the claim is
about behaviour that already ships, and the value is that the refusal is on record.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mount the agent-history route in a real browser
Round 1, finding 3. Nothing rendered this route's real module graph anywhere. The
unit tests mock react-native, safe-area, svg, lucide and the icon assets away —
they have to, since react-native is Flow source vitest cannot parse — so a
component in this closure with no web build would have reached a device before it
reached a test. The render check is the only place the graph meets React Native
Web, and this route was not in it.
Two cases. The first mounts the route from the shell double and reads the screen:
"Agent Session History" and the worktree label the params half carried, no fault,
no console error, no CSP refusal, and the URL the page wrote for itself. That also
proves `init.route.params` end to end on a route that has a dynamic segment too,
which §1 of the design claimed and nothing checked.
The second pins the chunk. C5 is the first series whose success path pulls a
second chunk after the first paint, which on iOS goes through WKURLSchemeHandler
under `script-src 'self'`. The chunk is named from the builder's own route map
rather than guessed from the bytes, and asserted absent from what the first route
loaded, so this says the route came over the wire now.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say the real lifetime of the route-handoff refusal set
Round 1, finding 4. The comment claimed one line per reason "for the life of one
client", borrowing `createPageDiagnosticReporter`'s bound. The set is built inside
the `useMemo` keyed on `[client, router]`, so it is per hook instance: in practice
the memo is not recomputed, because `useRouter()` is expo-router's module
singleton and the page holds one client, but every screen calling the hook gets
its own set and a reason can be reported once per screen rather than once per
document.
Says that now, and why it is not tightened: a per-module set would outlive the
page's client, which is the lifetime the rest of these reporters are scoped to,
and there is no document-wide reporter to join without reaching into a contract
file the C2 lane owns.
Records the other half of the finding too, which came back confirmed rather than
changed: `console.warn` is right here. It is the vocabulary `page-bootstrap.ts:35`
already writes in, and a `fault` notify would be wrong twice — the shell drops the
generation on a page fault, and a navigation the page declined is not a failure.
Comment only; no behaviour change, 25 navigation tests unchanged and green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): wrap every router member that takes a target, not three of five
Round 2, finding 1. `...router` hands through everything this file does not name,
and two of the members it did not name take an href: `navigate` and `prefetch`.
`navigate` to a route outside `pageRoutes` went straight to expo-router and pushed
it into this document — the hole the tri-state exists to close, reopened under a
name nobody had looked at. No call site uses it today, which is why it shipped.
`navigate` is now wrapped exactly as `push` is: which of push-or-collapse it does
is a decision about this document's stack, and a target outside this document has
no such stack.
`prefetch` is decided the other way, explicitly. It is the one target-taker that
must never reach the shell: a prefetch is a background load, `navigate` is the
only thing the shell can be told, so handing one over would open a screen nobody
asked for. A route this document serves is prefetched here, which is what the
per-route chunk split makes worth doing; every other one is dropped without a
line, because a warm-up that did not happen is not a failure to report.
The docstring's "four members that can leave this document" is now five wrapped
members and a rule for which is which.
A list would rot, so the pin is derived: `HrefTakingRouterMember` reads the
parameter tuple of every member of `RouteHandoff` and `WRAPPED_HREF_MEMBERS` is
asserted equal to it in both directions. It reads the tuple rather than testing
assignability because `() => void` is assignable to `(href: RouterHref) => void`,
which would make `back`, `dismissAll` and `reload` target-takers and prove
nothing. Checked both ways: dropping `prefetch` from the list fails the compile
with "Type 'HrefTakingRouterMember' does not satisfy the constraint", and the
union resolves to exactly the five, with `back` and `setParams` outside it.
The pin is in the product module because `mobile/tsconfig.json` excludes tests.
The runtime test asserts each wrapped member is not the router's own function and
that `setParams` still is, so a hook that wrapped everything fails too.
Red first: 4 of the new cases fail against the previous file, 33 pass now.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): say per hook instance in the title too, not per client
Round 2, finding 2. The source comment was corrected in round 1 and this test's
title was not, so the two disagreed about the bound the refusal set actually has:
the set lives in the `useMemo`, so it is per hook instance, and a title claiming
per client is the stronger promise the code does not make.
Title only; the case and its assertions are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the agent-history render case does not cover
Round 2, finding 3. The docstring claimed the case is where the panel's closure
meets React Native Web, which overstates it. The shell double answers no RPC, so
the session scan fails and the panel paints its "Unable to Load" state: the
session list, its rows, the resume button and the scope tabs never render, and a
render-time gap inside any of them would pass this check.
Now says both halves — import-time evaluation of every module in the closure and
the panel's own chrome are covered, the list subtree is not — and names what
covering the rest would take: a double that answers `aiVault.listSessions`, which
is a different instrument and would put domain behaviour in this file.
Text only. This case moves to its own file on the extracted harness after the
merge with #21592; the corrected text travels with it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): import the handoff module once in its own test
My round-2 fold added `WRAPPED_HREF_MEMBERS` as a second import of
`./route-handoff.web`, which `import(no-duplicates)` fails in the focused-plugins
pass of the changed-code gate. Joined to the existing import below the mocks,
which is where an import of the module under test has to sit in this file.
Found by running the changed-code gate rather than by review: mobile tsc, whole
tree oxlint and the suite were all green with it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give agent-history its own render file on the extracted harness
The render check gained browser cases from three domain series at once, each
under the `.mjs` cap of 600 counted lines alone and no two of them together: at
|
||
|
|
b7c06900e2 |
fix(mobile): give reanimated mapper hooks the inputs esbuild never writes (OTA phase C, C1.10) (#21592)
* refactor(mobile-web): extract the page render harness The shell double, the CSP/bridge constant readers and the bundle server were private to mobile-web-app-render.test.mjs, so a second check against the same page had no way to reach them. Moved as-is into a module both can import; the double also gained a `replies` map so a check can answer one method and leave the refusal in place for everything else. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give reanimated mapper hooks a dependency array The bottom drawer never slid onto the screen in the web shell page: `progress` animated to 1 and `withTiming` reported finished, but the sheet kept the translateY of the animation's first frame and sat one viewport below the fold, with its invisible backdrop swallowing the next touch. Cause, bisected in the browser: `useAnimatedStyle` reads its mapper inputs from `updater.__closure` (hook/useAnimatedStyle.js), which only Reanimated's Babel plugin writes. The page is bundled by esbuild, which runs no Babel, so `__closure` is undefined; with no dependency array either, `inputs` is empty and `startMapper` registers a mapper that listens to no shared value. It runs once and never again. Reanimated does throw for exactly this, but behind `__DEV__`, which the bundle builds out, so the page reports nothing. The rAF loop stopping after one write is the observable end of it. Not a WebKit fault. Headless Chromium parks the sheet the same way (translateY(843) vs WebKit's translateY(841)), so the earlier JavaScriptCore-vs-V8 reading does not hold, and the pin added here runs on both engines rather than on Chromium alone. WebKit is downloaded in the mobile_web_app job for it. Every mapper-backed call site takes the same array, not just the drawer's: RightDrawer and DragReorderList are the same defect on the same bundler. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the reanimated hooks that need a dependency array The drawer pin covers MountedBottomDrawer only, and the failure mode is silent: a new `useAnimatedStyle`, `useAnimatedProps` or `useDerivedValue` without an array animates once on the phone's native build and freezes in the web page, with no error on either. Parsed rather than grepped so a call spanning lines, or one whose second argument is not an array, is still seen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): state motion-on as the drawer pin's precondition Under `prefers-reduced-motion: reduce` Reanimated finishes `withTiming` in one frame, so a mapper that only ever runs once still writes the final translateY and the pin goes green on the broken build. Measured: the unfixed bundle under reduced motion lands at translateY(0) with the sheet on screen in both engines, which is also what the Android emulator does with animator scale off — the same single write, not a healthy animation. The context now says no-preference and the page is asked to confirm it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census useAnimatedReaction, whose deps are its third argument Same fallback as the other three (hook/useAnimatedReaction.js:26-34), so the same silent freeze applies. Its shape is not the same: the array is argument three, behind `prepare` and `react`, and both callbacks run inside the one mapper it starts, so both count as updaters. Indexing it like the others would have read the `react` callback as the array. No call site today; this is the gate for the first one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): require the dependency array to list every value the updater reads An array proves a call was written, not that the mapper listens to everything it reads. On web `inputs` becomes exactly that array (hook/useAnimatedStyle.js:338-341), so a value read but not listed is a value the mapper never hears about: the updater stops re-running when only that one changes. Same freeze as no array at all, in one prop rather than all of them. Reads only. The first fixture caught this check counting `opacity.value = v` as a read, which it is not -- a written value is an output, and demanding it in the array would be noise at every `useAnimatedReaction`. Assignment targets and increments are excluded; a value both read and written is still required. Verified against the tree by dropping `translateY` from the bottom drawer's array, which the census names at mounted-bottom-drawer.tsx:286. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): resolve the hook through the file's imports, not by spelling Matching the callee's text both missed and invented. `useAnimatedStyle as useAS` and `Reanimated.useAnimatedStyle` are the same hook wearing another name and went unchecked; a local helper that happens to be called `useDerivedValue` is not this hook and would have been flagged. Each local name is now resolved through the file's imports from `react-native-reanimated`, named, aliased or namespace member. A second argument that is not a literal array now counts as present rather than missing: the hook only needs an array to exist, and this file cannot see what a hoisted `const deps = [...]` holds, so completeness covers literal arrays only. Resolution can fail closed, which would read exactly like a clean tree, so the census now asserts it saw the calls before asserting none are missing. Checked against the tree by dropping `translateX` from RightDrawer's array, which it names at RightDrawer.tsx:156. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): select the drawer sheet by name, not by its corner radius The pin walked up from the handle to the first ancestor with a 16px top radius, so it found the sheet through a styling token. Change that radius and the pin reports `sheet: false` -- a red naming the selector rather than the animation it exists to watch, on a change that broke nothing. The sheet now says what it is. `testID` on the RN side renders as `data-testid` on web (react-native-web createDOMProps/index.js:832), which is the one line of product change this needs. Re-verified after retargeting: still red on both engines with the dependency arrays removed (translateY 843.271 chromium, 841.447 webkit), green with them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile-web): say why the motion option must precede navigation Reviewer follow-up on the reduced-motion guard. The context option and the `goto` order are both load-bearing, and nothing in the file said so: Reanimated reads `matchMedia('(prefers-reduced-motion: reduce)')` once into a module-level const at import (ReducedMotion.js:8-10), so a `page.emulateMedia()` after navigation would leave the assertion passing over a value already latched true. Comment only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): name the drawer pin's precondition instead of asserting past it CI's Linux WebKit failed this pin at `matrix(1, 0, 0, 1, 0, 844)` -- exactly the viewport, the mount-time value, not a first-frame 843.x. Nothing animated there, so the pin was reporting a parked sheet without being able to say whether the mapper was subscribed. Two different faults, one message. `requestAnimationFrame` separates them and sheet writes do not. `withTiming` schedules a frame per step (valueSetter.js) whether or not a mapper listens, so frames across the window mean the shared value moved; the assertion now names that. Counting sheet writes as the precondition inverts the diagnosis: measured on the broken build, "written more than once" fires first and calls the defect this pin exists to catch an engine that does not animate. Sheet writes stay, as a second statement of the subject and as context in the transform failure, which now reads "1 style write(s) on the sheet across 30 frame(s)" on the broken build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): wait for the drawer to arrive, not for a clock The pin paused a fixed 1s after the sheet opened and then read the transform, which makes it a race on a loaded runner: a healthy engine that is merely slow reads as parked, and the red names the transform rather than the wait. It now waits for the settled transform, times out at 15s, and asserts on whatever it found either way, so a genuinely parked sheet gives the same red with the timing assumption removed. On the broken build that red now reads "1 style write(s) on the sheet across 3635 frame(s)", which says the fault in one line. Aimed at CI's Linux WebKit red rather than proven against it: eight container runs on the Playwright Linux image never reproduced that failure. See the report for what the container did and did not show. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): stop asserting on the sheet's style-write count The count cannot carry an assertion in either direction. Measured under `--cpus=0.35` in Playwright's Linux image, a healthy page starved of frames reaches translateY(0) in a single write, because `withTiming` covers the whole 180ms in one step when one step is all the frames it gets. "Written more than once" would have redded that page, which is a CI runner under load -- the exact situation this pin keeps meeting. So the transform is the only subject, `requestAnimationFrame` during the window is the only precondition, and the write count is context in the failure text. Also worth recording against the CI log: exactly `matrix(1, 0, 0, 1, 0, 844)` is reproducible here on the broken build, as the single mapper run landing at progress 0. It is the mapper's signature as much as a dead engine's, so it does not on its own say which failed -- the frame and write counts now printed beside it are what separate them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count a value read under a unary operator as a read `isWriteTarget` took any prefix-unary parent for a write, so `!hidden.value`, `-offset.value`, `+x.value` and `~x.value` were dropped from the reads the dependency array has to list. A style that gates on `!hidden.value` would have passed the census while its mapper never listened to `hidden` -- the exact freeze this file exists to catch, hidden by the check meant to catch it. Only `++` and `--` mutate, so the prefix branch is narrowed to those two. Postfix needs no narrowing: `++` and `--` are the whole set there. Red-first with a negation fixture and a unary-minus fixture; the increment fixture holds the other side, that a value only incremented is still not required. Found by a review bot on #21592. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
57fdf68ab3 |
feat(mobile): let the page hand a link to the device through the shell (OTA phase C, C2.3) (#21597)
* feat(mobile): answer externalLink on the shell side of the bridge (OTA phase C, C2.3)
A page has no way to open a URL outside itself: `Linking.openURL` is the
app's, and inside the shell the page is a document that cannot reach it. Adds
`notify { name: 'externalLink', url }` and a new grant name of its own in
`MOBILE_WEB_SHELL_GRANTS`, rather than a verb of `navigate` — `navigate`
opens a screen this app carries, this hands a URL to whatever the device
opens it with, and a shell implementing one and not the other is a real shell
the route policy has to be able to describe.
`https:`, `http:` and `mailto:` only, and broad inside that: any host, any
path, because a grant that named GitHub would grow a row per provider. The
rule is parsed rather than prefix-matched, since a scheme is what a URL
parser says it is and `startsWith('https:')` reads one out of
`javascript:alert("https://x")`. It is enforced at the frame as well as at
the page's call site, so a page that skipped its own check still cannot reach
the device handler. Bounded by the route href cap, per the ruling.
`BRIDGE_PROTOCOL_VERSION` is not bumped. Inert until a consumer exists: no
call site and no barrel is touched here.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): plumb externalLink through the bridge hook probe (OTA phase C, C2.3)
The hook's own suite builds its caller options inline, so the new required
option made it stop typechecking. `tsc -p tsconfig.json` excludes test files;
only the tests-typecheck ratchet saw it.
Adds the case that goes with it: a URL the page hands over reaches the
caller that can leave the app, and nothing reaches the navigate path.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): let the page post an externalLink over the bridge (OTA phase C, C2.3)
`notifyExternalLink(url)` on the page client, gated on the `externalLink`
grant and on the same scheme rule the frame enforces.
Checked twice on purpose. Nothing crosses back for a notify, so the boolean
is the only answer a tap gets: a page that posted a URL the shell's reader
then dropped would report "opened" into a frame nobody acted on, which is
precisely the dead tap the grant exists to rule out.
False before `init`, false after `close`, and never a throw — the callers are
tap handlers with no catch around them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): add the external-link seam the tasks call sites will use (OTA phase C, C2.3)
One module with a `.web.ts` sibling, which is the shape every platform gap in
this bundle already takes. Native is `Linking.openURL` with the rejection
swallowed, because every caller is a tap handler and `openURL` rejects for a
URL no installed app claims. Web posts the `externalLink` notify after the
same scheme check the frame enforces, names its refusals and throws nothing.
The opener is published by the entry rather than read from context, for the
reason `publishPageStorage` is: the callers are plain functions in render
trees the provider does not wrap. A document that published none refuses
every URL, which is the right answer for a page with no shell.
Registered in `web-overrides.json`: inside the shell's WebView, react-native
-web's `Linking.openURL` opens the URL in that WebView and replaces the page
rather than handing it to the system browser.
No call site and no barrel is touched. The consumer PR swaps them onto this.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state the real reason the page cannot open its own links (OTA phase C, C2.3)
The override reason claimed react-native-web's `Linking.openURL` opens the
URL in the shell's WebView and replaces the page. It does not. Read from
react-native-web 0.21.2: `openURL` calls
`window.open(url, '_blank', 'noopener')` and resolves whether or not anything
opened; only a `tel:` URL assigns `window.location`, and none of the three
allowed schemes is one.
The true failure is the worse one and the better argument for the verb. Both
shells refuse `window.open` outright, measured in their own sources: iOS sets
`javaScriptCanOpenWindowsAutomatically = false` and returns nil from
`WKUIDelegate`'s `createWebViewWith`; Android sets the same flag false, calls
`setSupportMultipleWindows(false)` and returns false from `onCreateWindow`.
So nothing opens, `openURL` resolves anyway, and the native path reports
success into a tap that did nothing — precisely the dead tap the grant exists
to rule out.
The seam's own comment now says the same, so the two files cannot drift.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): report a URL the phone could not open (OTA phase C, C2.3)
The shell swallowed `Linking.openURL`'s rejection. Nothing crosses back to
the page for a notify, so an open that failed — a `mailto:` on a phone with
no mail account — was silent on both sides. That is the one dead tap this
verb does not rule out, and it was the only one with no record at all.
Warned with the URL and the error, in the shape the two neighbouring reports
in this screen use, and still not rethrown: this runs on the native frame
handler. The contract now asks for the report rather than only for the
absence of a throw.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forward the URL the parser read, not the string the page sent (OTA phase C, C2.3)
The scheme check reads the protocol through the WHATWG parser, which strips
tab, LF and CR from anywhere in a URL and trims leading C0 and space before
the scheme is visible. So `ht\ntps://example.com`, `https://example.com/a\r\n`,
` https://example.com/a ` and `https:example.com` all passed the check, and
both sides then forwarded the original string. Not a scheme escape — the
parser had already decided the scheme — but the device handler was given a
URL the check never looked at, which is a dead tap through an allowed URL.
`readBridgeExternalLinkUrl` answers the parsed href, and the page posts it
and the host forwards it. Normalizing rather than comparing, because
`https://example.com` differs from its own href by a path slash: refusing
what differs from its normalization would refuse an ordinary URL.
The cap now applies to the normalized form as well as the raw string, since
percent-encoding expands and a string inside the cap can leave it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): exercise the seam's default opener instead of a published one (OTA phase C, C2.3)
The case named for a document that published no opener published one first,
and `post` is module state every earlier case had already set, so the default
at the top of the module was never the thing under test. The only assertion
was `not.toThrow()`, which passes against any implementation.
`vi.resetModules()` and a fresh import, and the refusal reason is asserted.
Confirmed to discriminate: flipping the default to `() => true` turns this
case red and leaves the other three green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): build the openURL rejection per call, not at mock setup
The case added for a failed open used
`mockReturnValue(Promise.reject(failure))`, which builds the rejected promise
at setup time. Nothing attaches a handler until the notify frame arrives
several awaits later, so the suite reported an unhandled rejection and exited
1 with every test passing — a red run that reads as green in the counts
alone.
A fresh rejection per call closes the window, and `openUrl` is reset between
cases so the mock cannot leak into one that does not expect it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): guard the openURL failure that escapes a tap handler (OTA phase C, C2.3)
`Linking.openURL` validates before it returns anything: `_validateURL` is an
`invariant` that throws for an empty string (react-native 0.83.10,
`Libraries/Linking/Linking.js:117-123`). So the seam's `.catch` was attached
to a promise that, in that case, never existed, and the throw went straight
through a tap handler — contradicting the module's own claim to be safe in
one.
Both failure modes are now caught and reported, and neither is rethrown. The
test double validates the way the real module does, because a mock that only
rejects cannot reproduce the failure that escapes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): collapse the duplicate openURL wrapper onto the seam (OTA phase C, C2.3)
`mobile-pr-url.ts` was the seam's native body already, byte for byte, written
before it. It now re-exports the seam under its own name, so the empty-URL
guard and the failure report reach its four callers too.
Nothing changes natively: the seam's native form is what those callers were
running. On the web they would now post the notify instead, which is the
behaviour they should have had; it is unreachable today, since none of them
is in a page route's closure.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read externalLink end to end over the port pair (OTA phase C, C2.3)
The pair harness grew `externalLinks` and nothing read it. This is the
`navigate` twin's shape, over the six normalization inputs: what the page put
on the wire and what the shell forwarded are the same strings, read back off
the frames rather than recomputed, and nothing reaches the shell's client.
Two halves on purpose. The page normalizes before it posts, so over the
client the host only ever receives an already-normalized URL and forwarding
it raw would pass — the frame injected straight into the host at the end is
what holds the host to the rule on its own. Confirmed to discriminate:
reverting the host to forward `message.url` turns that assertion red.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): match the neighbours on three small inconsistencies (OTA phase C, C2.3)
Three of a kind, none behavioural:
- the notify guard's docstring had a 109-character line; reflowed
- the shell's could-not-open warning passed three arguments where the three
other reports in that screen pass two; it now passes `{ url, error }`
- the native seam's suite built its rejection with `mockReturnValue`, which
is the shape `52191bab02` removed elsewhere; it now builds one per call,
through the same double that validates the way the real module does
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
f1a901e974 |
fix(native-chat): suggest verified OMP terminal commands (#20672)
Co-authored-by: Nafisul Haque <100821672+nafisul-haque@users.noreply.github.com> |
||
|
|
428558b941 |
fix(mobile): let the shell's page paint a file preview (OTA phase C, C3.0) (#21591)
* fix(mobile): let the shell's page paint a file preview A file preview has one shape on the wire: the desktop answers a base64 body and `normalizeMobileFilePreviewResult` composes `data:<mime>;base64,<content>` for React Native Web's `Image`. Under `img-src 'self'` the browser refuses to load it, so every image preview in the page paints nothing — reproduced in the render check, which logged the refusal naming `img-src 'self'` before this. `data:` is granted to images and to nothing else, so what it admits is what the page itself composed out of a reply it already holds; `script-src 'self'` and `connect-src 'self'` are untouched, and `blob:` is not added because nothing in the closure needs one. Both platform pins narrow from "the header contains no `data:`" to "`data:` appears on `img-src` and nowhere else", which is the check that still fails if a later directive grows one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what the data: image case actually loads through Round 1 is right on both counts. In react-native-web 0.21.2 the hidden <img> the Image component renders carries `alt`, `style`, `draggable`, `ref` and `src` and no load handlers at all — it is there for the browser's image context menu and for `getBackgroundSize()`. The load signal comes from `ImageLoader.load`, which is `new window.Image()` with `onload`/`onerror` on it, so the `new Image()` in this case is the same mechanism the screen's own load runs through rather than a stand-in for it. And the screen maps `onImageError` to "Unable to load preview" (`MobileFilePreviewScreen.tsx:282`); "Binary preview unavailable" is the normalizer's `binary_file` branch, which a CSP refusal never reaches. Comment only. No assertion moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the img-src data: bound as the destination, not provenance "admits only what the page itself built" read as a provenance guarantee, and CSP has none to give: `data:` is matched as a scheme, so the directive admits any `data:` image URL and the browser cannot tell one the page composed from one it was handed. Nor is the content the page's own — the mime type and the base64 body both come from the host, and `normalizeImagePreviewResult` only checks the mime type is a non-empty string. The true bound is where the URL goes: it is never fetched as anything but an image, `img-src` is the only directive admitting it, an image fetch executes nothing (an SVG inside an `<img>` runs no script), and `script-src 'self'`, `connect-src 'self'` and `object-src 'none'` are untouched. Both copies reworded identically, since they are kept in step by the render check's own policy comparison. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
edd9e3125b |
fix(mobile): give the page its height and its long press; C1.7 device proof (OTA phase C, C1.9) (#21589)
* fix(mobile): give the Route A document the height its mounted tree measures against (OTA phase C, C1.9) The document this builder emits carries no stylesheet, so `html`, `body` and `#root` have no height, and every box react-native-web lays out below the mount is `flex: 1` against a parent that measures 0. The collapse is silent in every check that existed: the entry stamps `mounted`, the route tree commits, `innerText` holds every row, and the accessibility tree reports each one at the offset it would have had. Nothing is painted below the header, and nothing takes a tap — the list sits inside a scroller the collapse clipped, and a phone reads it to VoiceOver while no row responds. Lane C1.7 found it on both an iPhone 17 Pro simulator and a Pixel 9 Pro emulator, and the same bytes reproduce it in headless Chromium. The fix is the reset Expo's own web template ships for a react-native-web root, emitted inline because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet react-native-web injects at runtime; a linked asset would paint the collapsed layout until it landed. The render check gains the assertion that would have caught it: the root's box measured against the viewport, and the one control this route paints with no RPC answered — the New Workspace button, positioned against the bottom of the root, which the collapse moved to y = -72 — asked for by `elementFromPoint` at its own centre. Laid out is not reachable, so the check is a hit test and not another read of the DOM. Without the reset it fails `expected +0 to be 844`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the page have the long press WebKit was taking (OTA phase C, C1.9) The shell's WKWebView is built with the default text interaction, so WebKit installs its selection assistant over the page. A hold on a worktree row raises the selection loupe over the row's own text and the touch is cancelled before the page's responder sees it, which leaves every long-press action in the page dead on iOS while a tap works. Lane C1.7 measured it: the same injected hold opens the row action sheet on the native list and on the page in the Android WebView, and does nothing in the page on iOS. It is not the document's to fix, which the device disproved one rule at a time: `-webkit-touch-callout: none`, `-webkit-user-select: none`, and both together all left the loupe and left the hold undelivered, and headless Chromium confirms the property computes to `none` on the page's text, so the CSS reaches it and WebKit's own gesture wins anyway. The cost is real and named here rather than discovered later: the page has no text selection on iOS, so selectable `Text` — markdown, diff rows, file preview, chat — cannot be selected there until a page-side copy affordance exists. Everything the shell already forbids is unchanged, and Android is untouched. No unit test: the module's Swift checks compile the seven WebKit-free logic files and never import WebKit, so a `WKWebViewConfiguration` cannot be built in them. The device proof stands in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the root reset the template's bytes, not a copy with an addition The comment said the reset is what Expo's web template ships, and `margin:0` was not in it. `@expo/cli@55.0.36/static/template/index.html` carries height, `overflow` and the root's flex box and nothing else, and react-native-web emits `body{margin:0}` in the sheet it injects at runtime, so the addition only covered the frames before that sheet landed. Nothing pinned it either: removing it left all 60 tests green, which is the other way of saying it was never load-bearing. Dropping it makes the string one thing with one source instead of a copy to keep in step with two. The pins on the rest of the reset are unchanged, and so is the frame that mattered: the root still has a definite height before the first paint, which is what the collapse needed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the render check with the root formatter `config/scripts` is formatted by the root oxfmt, not mobile's, and CI checks neither, so a 102-char line I added sat over the root's `printWidth: 100` with nothing to catch it. Reflowed by `./node_modules/.bin/oxfmt --write` from the repo root; no behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what the root reset shares with Expo's template, not that it is its bytes "The bytes Expo's web template ships" is false and checkable: the template's own block is pretty-printed with comments and trailing semicolons at 410 bytes, and this string is 112. What is actually true, and what the next reader needs, is that it carries the same declaration set and the same `id="expo-reset"`, minified. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin every rule of the root reset, not two substrings of itself The check read the constant back against itself: `toContain(MOBILE_WEB_APP_ROOT_RESET)` plus two substrings taken off that same constant. A rule dropped from it took the assertion with it, so `body{overflow:hidden}`, `flex:1` and the `expo-reset` id were unpinned — and the render check stays green without the overflow rule, so nothing else held them either. Each rule is now a literal written here, named one at a time so a failure says which one went, and the id is pinned beside them. Verified red-first: removing the overflow rule, the `flex:1`, or the id each fails this test and only this test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
1ef947394b |
feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2) (#21582)
* feat(mobile): answer navigate-back on the shell side of the bridge (OTA phase C, C2.2)
A page served at `/` holds the one history entry its entry wrote with
`replaceState`, so `history.back()` goes nowhere and a page Back button is
dead. The only stack with somewhere to go is the native one the shell pushed
the page onto.
Adds `notify { name: 'navigate-back' }` to the closed client union, gated on
the existing `navigate` grant rather than a name of its own: an app that can
open a screen can close one, and a new grant name would leave every route
declaring it native on every shell already shipped. `MOBILE_WEB_SHELL_GRANTS`
is unchanged and `BRIDGE_PROTOCOL_VERSION` is not bumped.
`bridgeNotifyRefusal` grows a name-to-grant table, since this is the first
notify whose name is not its grant's. The shell screen pops its own stack and
answers false when there is nothing left, which the host logs as
`navigate-back-refused` — nothing crosses back to the page either way, so
silence there is indistinguishable from a Back button that worked.
Inert until a consumer exists: no page posts the name yet.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2)
`useRouteHandoff` wrapped `push`, `replace` and `dismissTo` and left `back`
as expo-router's own, which inside the page pops a history of one and does
nothing. It now pops locally when the document grew a stack of its own, and
otherwise posts `navigate-back` for the shell to pop the native stack.
This is what makes the Tasks header's `onPress={() => router.back()}`
(`src/tasks/mobile-tasks-screen-chrome.tsx`) work once the C2.1 consumer
routes that screen's router through this seam; the barrel still reaches
expo-router directly, so nothing calls this yet.
A shell that granted no `navigate` falls through to the local router rather
than throwing out of a tap handler. A shell that granted `navigate` but is
too old to know the verb refuses the frame as `unrecognised-message` and
logs it; neither is distinguishable from the page, and the fallback goes
nowhere in both — which is exactly where Back already went.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): count back among the members that leave the page document
The header said three wrapped members driven by one answer. `back` is a
fourth, and it is not driven by that answer: it carries no target, so the
document's own stack decides it rather than the shell's route list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): complete the bridge hook probe in its two local literals
`Probe` grew `backPops`, and two cases build the object inline rather than
through `mount`. `tsc -p tsconfig.json` excludes test files, so only the
tests-typecheck ratchet saw it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): measure the stack the page Back button rests on (OTA phase C, C2.2)
`useRouteHandoff().back()` asks expo-router's `canGoBack()` whether this
document can serve a back itself, and that answer is React Navigation's, so
no unit test settles it. The render check now measures it in the browser it
actually runs in.
Read through `router.back()` on `/h/[hostId]/edit`, a real route of this tree
whose chevron is expo-router's own back, because the page exposes no handle
to call `canGoBack()` on and a global added for a test would ship forever.
Measured: the router has nowhere to go on the document the shell opens, and a
location change does not give it one either. So the handoff's `canGoBack()`
gate answers false for everything the shell or the browser can do to the
page, and its local branch belongs to a push the page makes through the
handoff itself.
The shell double now records every notify the page posts and takes the grant
list as a parameter, so a control that handed something to the shell can be
told from one that did nothing. The first case asserts a real tap crossing
the bridge, which is what makes the two absences after it evidence.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse a second stack pop while the first is still queued (OTA phase C, C2.2)
`canGoBack()` and `back()` disagree about time. The first reads the committed
navigation state; the second only adds `GO_BACK` to expo-router's
`routingQueue`, which `useImperativeApiEmitter` drains from an effect. Two
`navigate-back` frames delivered in one native batch therefore both read the
stack the first pop has not left yet, both queue, and a three-deep stack
unwinds past the screen the page was opened over. The host forwards every
notify it is granted, so nothing upstream coalesced them.
`useShellStackPop` owns the pop and latches it. The latch clears on the
committed route rather than on a timer, because that commit is the first
moment `canGoBack()` answers for the stack the pop actually left; a pop that
takes this screen off the stack unmounts it and takes the ref with it.
`onNavigateBack` now answers `popped` / `nothing-to-pop` / `pop-pending` so
the `navigate-back-refused` diagnostic is true for the frame it names, and
the log dedupes per reason rather than burying the second behind the first.
Driven against expo-router 55.0.18's own `global-state/routing.js`,
evaluated verbatim with only its externals stubbed: a mock of `canGoBack`
is what hid this.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the notify grant table total over the union (OTA phase C, C2.2)
The table was `Record<string, string | undefined>` indexed with a plain
string, so a notify name with no row returned undefined, read as ungated,
and the host acted on a frame it had never granted. Adding a member to the
envelope's notify union raised no error anywhere — the hole was silent.
Keyed on `Extract<BridgeClientMessage, { type: 'notify' }>['name']` with
`string | null` values, an omitted row is now a TS2741 on the table itself,
and `bridgeNotifyRefusal` cannot be asked about a name the table has no row
for.
Adds the `navigate` and `storage` rows, which were missing: the host was
enforcing the navigate grant for `navigate-back` but not for `navigate`.
Both are inert while every page is offered every grant, and load-bearing the
moment a grant is per-route.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): assert no page errors in the browser case that drives popstate (OTA phase C, C2.2)
The case that dispatches a synthetic `popstate` read its answer as "the page
did not move", and a throw under the page's fault boundary leaves the page
exactly there. Without the errors assertion the other two cases carry, that
absence was not evidence of what it claimed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the routing module's members instead of asserting them
The casting gate refuses the two assertions the loader used, and it is
right: a stub that stopped covering an import would have left the members
undefined and the test would have driven a half-evaluated module. Destructure
and check instead, so that case says so.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the two pops the stack latch never hears about (OTA phase C, C2.2)
The comment read as if the clear were exhaustive. It is not: a pop landing on
an equal pathname does not transition `usePathname`, and a `GO_BACK` the queue
discards commits no route at all — `routingQueue.run` shifts every action off
the queue whether or not `ref.current` is set.
Kept the pathname clear rather than moving to the navigator's `state` event.
The event would cover the first stick: `@react-navigation/core` 7.17.2 emits
`state` from an effect keyed on the navigator state object, and every pop
replaces it. It would not cover the second, which changes no state. And the
emitter is the navigator, not the routing module this hook is written and
tested against, so the switch cannot be earned by a test here the way the
queue behaviour was — it would rest on a mock of the signal under test.
Both sticks are bounded instead, in the commit that makes the latch one per
stack: the holder releases on unmount, so a stick lasts at most as long as the
screen that took it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold one pending stack pop per stack, not per screen (OTA phase C, C2.2)
`MobileWebShellScreen` mounts at both `app/h/[hostId]/index.tsx` and
`app/h/[hostId]/web.tsx`, and `/h/a/web` is deep-linkable over `/h/a`, so two
shells can be mounted over one native stack. A latch per screen left each of
them holding its own, and two frames from two pages still unwound two screens.
The latch is module-scoped and carries which screen took it, so a shell whose
own route commits cannot release a pop another shell is still waiting on. The
holder also releases on unmount, which is what bounds the two pops the
pathname clear never hears about: a latch nobody is left to release would
outlive the stack it guards and leave Back dead for the session.
Both screen suites now unmount their trees between cases, because a tree that
is only dropped is a screen still holding whatever pop it took.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the gated-notify name list nothing reads (OTA phase C, C2.2)
`BRIDGE_GRANT_GATED_NOTIFY_NAMES` existed so a caller could ask which names
ride a grant. Once the table became total over the notify union, the table
itself answers that and the only readers left were the two assertions that
read the list for its own sake.
Deleted with them. The behaviour they stood next to is kept: the protocol's
own names are still asserted ungated through `bridgeNotifyRefusal`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the routing-module guard actually catches (OTA phase C, C2.2)
The comment claimed the guard catches a stub that stopped covering an import.
It does not: evaluating the module with every stub dropped still defines all
three exports, because the module assigns them whatever its imports resolved
to, and the failure surfaces later as `TypeError: Cannot read properties of
undefined (reading 'navigationRef')`.
What the guard does catch is an expo-router upgrade that renames or removes
one of the three members this test drives.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the second shell mounted across the holder's removal (OTA phase C, C2.2)
The harness swapped the tree's root element between a single shell, a
fragment of two, and a single shell again. Each swap remounts everything
under it, so the callbacks the cases held belonged to unmounted hooks. One of
those could still take the module-scoped latch, and the instance that took it
was already gone, so nothing was left to release it — the last case in the
file leaked the latch into whatever ran next.
The root is now one component with a slot per shell, so removing the holder
leaves the second shell's instance alone, and every pop is taken through a
callback re-read at call time.
`afterEach` now asserts the latch is clear by mounting a screen after every
other one is gone and requiring it to pop. Without it this leak was invisible:
it surfaces only in a following case, and the case that caused it was last.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
5a208ce6fe |
chore(mobile): repin the recording corpus to main's tip after #21566 (#21588)
#21566 re-recorded the corpus with `baseline` set to its own branch commit `47d24d324e`, which the squash merge left unreachable from main. The pin guard on the push to main fails, and so does the pin job on every open pull request, since the merge ref cannot reach that commit either. Repin to main's tip `889c2b562f` and re-record from that tree. Every golden's body is byte-identical to the one #21566 recorded; only the `baseline` header line moves (788 files, one line each). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |