mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +00:00
4bab736f9097317ad6c4bcb90f99d00a4bc4e31c
819
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
63866c1e27 |
fix(mobile-web): page inputs lose the browser focus ring and hairlines draw one device pixel (OTA phase C follow-up) (#22569)
* fix(mobile-web): drop the UA focus ring from page text inputs Chromium rings every focused text field (:focus-visible); no native TextInput paints one. A zero-specificity rule in its own inline block beside the Expo root reset removes it for every page input; buttons keep the browser's ring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): draw page hairlines one device pixel thick react-native-web pins StyleSheet.hairlineWidth to 1 CSS px, three device pixels on a 480 dpi phone; native draws one. A build shim replaces that one assignment with React Native's own formula (roundToNearestPixel(0.4), else 1/ratio), so every page hairline matches native without touching components. A rendered check at a real device scale (Playwright's emulated scale floors borders to CSS px, which no phone does) measures both parity fixes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): apply the hairline shim to react-native-web's CommonJS build The page's dependencies require react-native, so esbuild resolves every importer to react-native-web's dist/cjs build, which the previous filter did not match: the shipped bundle still assigned hairlineWidth=1. The filter now matches both builds, the rendered check requires the package the way the page does, and a builder test reads every hairlineWidth assignment in the bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): draw page hairlines at a width WebKit paints too 1/ratio is exactly one device pixel, and WebKit floors that to 0 and paints nothing (0.3333px at a scale of 3), so the iOS shell would have lost every hairline. The shim now uses native's device-pixel count plus half a pixel; both engines floor a border to whole device pixels, so each paints what React Native paints at ratios 1, 2, 3, 3.5 and 4, measured per engine. The rendered parity check now runs in WebKit at a device scale of 3 as well as Chromium. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): check the parity style in the existing root-reset build Drops a second full build that read one HTML string, plus two assertions that tested the constant against itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): round the page hairline up to the 1/64 px layout step Half a pixel over native's count kept borders at one device pixel but made the separators drawn as `height: StyleSheet.hairlineWidth` straddle two rows at about half of all offsets. Both engines lay out in 1/64 CSS px, and WebKit stores an exact 1/3 as 21/64 and paints nothing, so the width is now native's device-pixel count over the ratio, rounded up to the next 1/64 (22/64 at 3). The rendered check adds a separator at a 10.1 px offset in Chromium and WebKit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5b6a857e41 |
fix(mobile): route the bottom drawer's keyboard through the platform seam (OTA phase C follow-up) (#22556)
* fix(mobile): route the bottom drawer's keyboard through the platform seam Fill-mode sheets called Keyboard.metrics() directly, which react-native-web does not implement, so opening one on the page threw and the shell re-downloaded the workspace. The drawer now reads useSoftKeyboard, whose native half seeds from metrics() and carries the event duration, and whose web half answers from the window (duration 0). The fill/content-sized seed rule and resolveBottomDrawerKeyboardInset are unchanged. A census keeps Keyboard.metrics/addListener inside the seam plus the tab-sheet hide wait. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): retire the drawer's exemption from the page keyboard census The bottom drawer now reads the keyboard seam, so no module in the source-control or review closures names react-native-web's Keyboard stub. The census also flags Keyboard.metrics, which the stub lacks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the drawer an imperative keyboard pair from the seam The seam now exports subscribeSoftKeyboard and currentSoftKeyboardHeight beside its hooks. The drawer's effect is back to its original shape with only its Keyboard calls swapped for the pair, and useSoftKeyboard is back to {height, visible} with no metrics() seed. Seeding every consumer opened an iOS window between willHide and didHide where metrics() still reads open. The web pair answers from visualViewport, so it stays silent inside the shell and lifts sheets in a plain mobile browser. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start the web keyboard subscription from the current strip A keyboard already covering the page when subscribeSoftKeyboard attached never produced onHide when it closed, so the occlusion hook and a seeded fill sheet stayed lifted. Outside the shell only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9cdbc0c128 |
fix(mobile): keep the shell's window insets out of the page WebView (OTA phase C follow-up) (#22549)
* fix(mobile-web): stop the page declaring viewport-fit=cover The shell already pads the WebView out of the status and navigation bars. With viewport-fit=cover, Android's edge-to-edge WebView still reports the window's bar insets through env(safe-area-inset-*), which react-native-safe-area-context on web reads, so every page-side SafeAreaView padded a full bar a second time. Without it env() reads 0 and the shell's pad is the only one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the shell's window insets out of the page WebView WebView M144+ forwards the window's systemBars and displayCutout insets to CSS env(safe-area-inset-*) for every WebView, and Chromium applies them regardless of viewport-fit. The shell already pads the WebView out of both bars, so every page-side SafeAreaView (expo-router's DefaultNavigator and the session header) padded a bar a second time. M139+ likewise resizes the visual viewport for ime(), which the shell has already done by shortening the WebView. The WebView now sees those three types zeroed, per Android's "zeroing" approach (not CONSUMED, so later changes still reach it). A listener replaces the WebView's own onApplyWindowInsets, so the zeroed set is passed back into it. iOS needs nothing: the WKWebView uses contentInsetAdjustmentBehavior = .never inside the padded shell and reports zero insets. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile-web): say why the page declares no viewport-fit The earlier comment claimed dropping viewport-fit=cover makes env() read 0 on Android; Chromium's WebView applies the safe area regardless of viewport-fit. The page simply never asks to extend under the bars, and the shell owns the safe area. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the page inset zeroing private to the shell view The transformation has no honest JVM test (the builder runs as SDK 0 there and drops every inset type), so it moves into MobileWebShellView.kt as private members instead of standing alone. The listener comment now covers both the P-R listener and the S+ onApplyWindowInsets path it replaces, and the page document's comment says the env() zeroing is Android's; on iOS the padded WKWebView reports none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
519bde81df |
feat(ipynb): render notebooks like a notebook, with seamless click-to-edit cells (#22519)
* feat(ipynb): parse ANSI SGR sequences in notebook output Tracebacks and stream output carry terminal colour codes that the notebook printed raw. Splits text into styled runs (16/256/truecolor, bold, italic, underline) and drops non-SGR escapes via the shared stripper. * feat(ipynb): render notebooks like a notebook, not a form - Prose inherits the app UI font instead of a bare terminal font name, which Chromium could not resolve and fell back to Times (removes the now-dead resolveEditorFontFamilyOrInherit). - Markdown cells render by default; double-click or Enter edits them in the same Monaco surface code cells use. Code cells activate on press so a collapsing neighbour cannot swallow the click, which also retires the root pointer-capture deactivation (Monaco blur already covers it). - Code sits on its own tinted surface, the active cell gets a ring, and the editor sizes to its content so activating a cell no longer jumps. - The always-on 8-button toolbar and native select become a hover/focus toolbar (move, delete, and a menu for insert and cell type); the Jupyter [n] prompt turns into the run button. - Outputs show only the richest MIME representation, HTML renders in a script-less, no-network sandboxed frame sized to its content, and ANSI colours use the default terminal palettes. Adopts the MarkdownPreviewBody reuse, richest-MIME selection, extra raster MIME ranks, and CSP-sandboxed auto-height HTML frame from #18542. Co-authored-by: maxidiazbattan <maxidiazbattan@gmail.com> * chore(ipynb): drop the nbformat label and BETA badge from the notebook header parseIpynb already rejects notebooks without a v4 cells array, so the label carried no actionable information; the parsed nbformat field goes with it. Removes those catalog keys and the stale lowercase code/markdown ones. * fix(ipynb): keep cells pixel-stable when they switch to editing The preview and the live editor disagreed on four things, measured over CDP: - Font: the excerpt painted the bare "SF Mono" name (or --font-mono via its row class), while Monaco appended its own fallbacks and landed on Menlo. Both now use resolveEditorFontStack, the editor font plus the terminal fallback chain. - Line height: 20px rows vs Monaco's 21px. Both read CODE_EXCERPT_LAYOUT. - Gutter: a 48px line-number column plus 12px inset vs Monaco's 25px gutter. Notebook cells drop line numbers (the Jupyter and VS Code notebook default) and Monaco's decorations lane is the same 12px inset. - Rows: colorized blank lines collapsed to 0px, and a trailing newline had no preview row. Rows are fixed-height and a trailing newline opens an empty last line, matching the Monaco model. The [n] prompt and run icon now share one grid cell, so the hover swap keeps the label's box and centre. The commented-line tint moves to a theme token. * feat(ipynb): VS Code-style run gutter above a fixed execution count Replaces the in-place [n]/play swap from |
||
|
|
293c2508fc |
test(mobile): move the session closure pin past the structured tool-line module (#22430)
#22349 added `src/shared/structured-agent-session-tool-call-block.ts`, which the projection and live turn the session route already reaches import. The PR was src/shared-only, so its CI never ran the closure suite; main's pin stayed at 4215 while the closure measures 4216. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
52a1e2875b |
feat(orchestration): accept Muse model and effort for supervised workers (#22383)
* feat(orchestration): accept Muse model and effort for supervised workers `worker-start --agent muse` already launched, but `--model` was refused because Muse had no session-option catalog. Add one that maps worker preferences to `muse --model <id>` and `--reasoning-effort <level>`; it seeds no models, so native-chat surfaces show no picker. opencode stays without `--model`: the opencode 2 TUI (now shipped as `opencode`) rejects the flag, so the refusal now tells callers to rely on the agent's own config. Help, skill guide, and docs list valid `--agent` ids and the agents that accept `--model`. Refs #19823 * test(mobile): repin session route closure for the Muse option catalog |
||
|
|
ebed0964a2 |
feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage. Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com> Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com> Co-authored-by: John Cusack <johncusackccm@gmail.com> Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com> * test(agents): cover Muse remote hook registration * test(agents): cover Muse hook and source-control contracts * test(agents): exclude Muse hook metadata from script mode check * test(agents): keep Muse skill picker coverage stable * test(ai-vault): include Muse in every-agent fixture * test(mobile): repin Muse agent icon closure * fix(muse): detect questions and approvals from structured Muse signals Muse 1.3 fires no hook for request_user_input, so a pending question left the pane "working". Its internal reminder subagents also post hooks with their own session ids (even after Stop), which surfaced "tool failed" rows and flipped finished panes back to working. - Read pending questions from Muse's session log (user_input_prompt_requested/settled) via the existing transcript poll, now generalized from Codex subagents to Muse on main and relay. - Drop child-session hooks (SubagentStart ids, or turn_id === session_id). - Treat Notification permission_prompt as the approval wait; PermissionRequest also fires for auto-approved calls, so it only caches the approval card. - Ignore Notification copy as the prompt; poll replays are not new prompts or turn boundaries. - Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook. * perf(muse): parse only question events from the session log Most Muse session-log lines are large model/tool records. Filter raw lines by the user_input_prompt_ marker before JSON.parse via an optional readJsonlCursor line filter. * fix(muse): unwrap batched log records and scope questions to the live turn Review follow-ups: question events inside retained_frame batches were skipped, and a question left open by a crash or interrupt stayed pending for the pane's life. Share the history scanner's retained_frame unwrapper, and only report a pending question whose run_id matches the hook turn_id. * refactor(muse): drop type assertion in retained_frame unwrap * fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy --------- Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com> |
||
|
|
0e6862cbcc |
fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials Six failed-load screens share one Retry shape: re-dial a host that is not connected, otherwise re-read. On the page the re-dial is inert (`client-context.web.tsx:55`) and each screen's load already re-runs when the shell's client reconnects, so in the disconnected state that Retry did nothing at all. `connectionRetryAction` makes the decision once and answers null when a re-dial is needed and none exists; agent history, the file explorer root, the file preview, git history, the source-control status gate and the diff review render no Retry for null. The explorer's per-folder Retry keeps its control: it queues the folder, and the queue drains on the next `connected` whoever brought it back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page offers no re-dial, so no header offers one `forceReconnect` on the page was `() => Promise.resolve()`: the shell owns the connection and nothing in the document can re-dial it. The host header's Reconnect and the session header's "tap to retry" were wired to it and did nothing there. The context member is now nullable and the page's provider hands out null, so the compiler found every caller: both headers render no reconnect affordance for null, and the session status keeps the verdict label without promising a tap. Native providers and the recording adapters still pass a function, so nothing a phone renders changes. The session route's host-JSX parity hash moves for the header's extra null check; the page test doubles that stubbed the old inert re-dial now stub null. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the auth-failed banner cites the page's re-dial as null Three comments and the banner's override reason still said the page's `forceReconnect` was an inert `() => Promise.resolve()`, and cited `client-context.web.tsx` lines the previous commit moved. They now say null and point at the lines that hold it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the session closure gains the page's Retry decision `connection-retry-action.ts` is the one module the Retry fix adds to the session route's page closure, reached through the explorer, source control and git history it docks. Measured on this head with all five generators run first, and diffed against the pre-change closure: one local module added, none removed. Session route closure 4207 -> 4208 modules, local 1021 -> 1022. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the capability probe belongs on the page, and says why The push fence excluded `runtime-capability-probe.ts` because the session route and the host screen run it. The session half holds, and the probe works there: `status.get` carries no client identity and makes no write, the shell forwards it like any non-`native.` request, and the desktop's mobile allowlist admits it. The host-screen half no longer does: `codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which the bundle carries and the page hands to the native screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the session retry test's cast under its disable line The formatter wrapped the cast onto the line after the disable comment, which left it uncovered. The cast now sits on its own line directly below the SAFETY note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the agent-history Retry test mocks the pathname the handoff reads Main's page route handoff now subscribes to `usePathname` (#22300), and the Retry suite this branch added mounts that handoff with an `expo-router` mock that lacked it. Same one-line addition main made to the back-handoff suite. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the reload each hidden page Retry relies on Hiding a Retry on the page rests on the screen's load re-running when the shell's client reconnects, because nothing on the page re-dials. Only the explorer's folder drain pinned that. Each other site now has a case that starts unreachable with no Retry and asserts the load goes out on the client and state the reconnect delivers: agent history (status.get), file preview (the preview read), diff review (the snapshot load), git history (git.history) and source-control status (git.status, in the loaders suite because the panel test mocks the state hook). Each goes red when the `client`/`connState` dependencies it guards are removed; for source control that is both `loadStatus` and the `loadBranchCompare` it depends on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): one import of the transport types in the source-control loaders test CI's native code-quality audit denies the duplicate-import warning the reload pin added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
7240368726 |
feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived
The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): record why a hybrid shell update failed, on the device
A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): forget a removed host's recorded update failures
Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): show recorded update failures in Troubleshoot
A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the update-failure row doubles without casts
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forget a host's update failures once a newer generation commits
The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-pin the session closure for the shared journal producer #22299 added
main at
|
||
|
|
11db2b9a7d |
feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key The shell's page had no way to hear Android Back: every sheet inside it early-returned on web, so the key popped the whole session route. Adds the first negotiated shell-to-page frame kind alongside it. - `back-claim`, page to shell, declared in `init.accepts`: the document is holding the key, or has let it go. - `back`, shell to page, declared in `ready.accepts`: one press, dispatched to the newest consumer that takes it. A press nothing takes is handed back as a `navigate-back` rather than dropped. Both are optional fields on frames the other side already reads, so an old shell never hears a claim and an old page is never sent a press; each pops as it does today. No protocol bump and no stream opcode. `bridge-host.ts` was at its line cap, so the notify forwarder moves to `bridge-host-notify.ts` unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): Android Back closes the sheet on the page, not the screen Inside the shell's page every sheet early-returned on web, so one press left the session route with the sheet still open. The drawer, the right drawer and the file-preview prompt now claim the key through one seam on both platforms: `use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on the shell's. All sixteen session sheets render through `MountedBottomDrawer`, so the one claim there covers every one of them, and a census fails if a sheet bypasses it. `route-handoff.web.ts` claims while the page grew a stack of its own, and hands the press back when it did not. The shell takes the key off the navigator only while a claim is live: Android gets a `hardwareBackPress` handler that returns the host's own answer, iOS loses the stack's swipe-back. The claim is cleared on `document-started`, on a remount, on a new `ready`, on anything that takes the generation off screen, on the page's `close` and on dispose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a Back press closes a sheet on the real bundle The unit suites reach both halves of the lane but never the two together on a document a browser rendered. The render rig can now post a `back` frame, and the drawer check opens the Filter sheet, reads the claim off the notify list, sends one press and pins that the sheet closed with no `navigate-back` behind it. Red without the drawer's claim: the claim never arrives. Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is shared with the native app and mounts under no page provider in a bare tree, where `usePageBridgeClient` threw; the seam now reads the bridge through `usePageBridgeClientIfPresent` and claims nothing without one. Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route handoff, `bridge-page-back.ts` through the envelope. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): mirror the Back seam's latest values from an effect Three `ref.current = …` writes sat in render, which React replays and discards. Each moves into a dependency-list-free effect declared ahead of the registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already uses for the same reason: the caller rebuilds the value every render, so there is nothing to depend on, and `useRef` seeds the first mount. The registration still keys on the claim alone, so a rebuilt handler re-registers nothing. The web seam's test drops its two type assertions for a named fixture type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page says its Back claim again on every init The claim was edge-triggered and the shell forgets on purpose: it drops the claim answering every `ready`, and a host rebuilt under a live page — a client swap through forceReconnect, which leaves the WebView mounted — starts with none at all. A document still holding a sheet was then unknown to the shell, and the next press popped the screen out from under it. `init` is the shell saying it is here now, so the page answers each one with the state rather than with a transition. Posted after the session has taken the frame, so the gate reads that `init`'s own `accepts` and a shell that never named the claim still hears nothing. Nothing is said while nothing is held. Every `init` answering a `ready` comes from a host that dropped the claim first, so it already holds false; the only other one carries a rewritten route, where a stale true needs a `false` the page posted to have never left, and a port that refused that frame refuses this one too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a rebuilt host keeps the session's Back claim A host is rebuilt when the client under it changes, and the page document does not move: the WebView stays mounted, the session id holds, and the page is never told. The rebuilt host started with no claim and no `accepts`, so it refused every press and the navigator popped the screen out from under an open sheet. Two clients on the same generation leave the page nothing to refuse, so nothing made it re-ask and re-assert. What the page declared and what it is holding are facts about the session, the way `sessionEstablished` already is. `createBridgeHostBack` takes them as a seed, `readSessionBack()` hands them on, and the hook holds them stamped with the session so a record left by one never seeds the next. `dispose()` no longer reports the claim gone: a host retiring is not a document ending, and that report was the thing taking the key off a live sheet. Every reset path is unchanged and still has its own case — the page's `ready`, its `close`, and the session's own store for `document-started`, `remounted` and anything that takes the generation off screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
564f135248 |
fix(mobile): an accessory Enter ends the field's editing session, and the page's Enter survives a composition (#22300)
* test(config): the page's live input never submits under an open composition The emulator's page-only defect, in a browser: an Android soft keyboard keeps a composition open over the word being typed, so the Enter keydown carries `isComposing: true`, which is the condition react-native-web reads to skip `onSubmitEditing` entirely. Nothing reaches the terminal and the field keeps the text. The probe route now mounts `useTerminalLiveInputCommit` and the command dock's own field props, so keys enter through the browser rather than through a handle that calls the hook directly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(config): format the live-input render check Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page's live input submits under an open composition react-native-web's keydown handler withholds `onSubmitEditing` whenever the Enter keydown reports a composition — `nativeEvent.isComposing`, or the Android `keyCode` 229 that stands for it — which is a soft keyboard's normal state mid-word. Nothing reached the terminal and the field kept the text. Native Android's editor action has no such suppression, which is why only the page showed it. The field now also claims `beforeinput`/`insertLineBreak`, the browser's own end-of-line signal. react-native-web cancels every keydown it does submit on, so that event exists only in the cases it dropped, never twice; an IME still choosing a candidate reports `insertCompositionText` and is left alone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): list the live-input submit binding, and repin the session closure The `.web.ts` sibling needed its row in `web-overrides.json`, whose check lists exactly the overrides on disk. The session route's page closure moves with it: the callback ref and the binding it resolves to are both local, and the native sibling stays out, which is what the pair is for. modules 4207 -> 4209 (+2) local modules 1021 -> 1023 (+2) Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the key bar's Enter chip leaves the sent text in the live field The device trace's variant (a), which is what shots/23 was: the chip emits no DOM key event, so react-native-web's submit handling never runs and the accessory hook is the only thing that could end the field's editing session. With nothing held it takes the send-now branch, which flushes nothing and writes neither the capture state nor the field, so the text the PTY already echoed stays put and the next keystrokes append to it. Not a page defect: the held-text fallback only holds a trailing non-ASCII run, so ASCII leaves nothing held on native either. The unit case is on the shared hook for that reason. The probe route now models what the send actions do with 'allow-raw', so the check can see a control sent twice or not at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): the buffered command field has the live field's Enter gate too Three cases, one red. A plain Enter sends the draft and empties the field, which is `beginBufferedTerminalDraftSend`'s doing and stays a guard. The key bar's Enter chip in buffered mode is a plain terminal key: the accessory hook declines at its live-handle guard, one carriage return goes out and the draft is untouched, also a guard. The red one is Enter under an open composition. This field reaches its send through `onSubmitEditing` alone, so react-native-web's keydown gate swallows it exactly as it did for the live field, and the draft neither goes out nor leaves the field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): an accessory Enter ends the field's editing session, on both fields Two defects, one rule: after a control that ends the line, the terminal owns the echoed text and the field's editing session is over. The key bar's Enter chip (device trace, variant (a)) emits no DOM key event, so only the accessory hook could end that session. Its held-text branch does, through the flush; with nothing held it took `send-now`, which flushed nothing and wrote neither the capture state nor the field. Not page-only: the held-text fallback holds a trailing non-ASCII run, so ASCII leaves nothing held on native either. `send-now` now takes the same flush when the bytes end the line, and still defers the send to its caller so exactly one return goes out. The buffered command field had the live field's composition gate, because it also reaches its send through `onSubmitEditing` alone. It binds the page's line-break signal now too, which is why the seam is named for a terminal text field rather than for the live input. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the submit binding holds the handler its first render was given Found by pullfrog on #22300. The binding refreshed its handler ref only when the callback identity changed, so a caller memoizing on an empty dependency list was bound once and never again. The buffered command field does exactly that: its submit closes over handleSend, a per-render function whose guard reads client and activeHandle, both null until effects supply them, so the page's line-break submit could never pass that guard. The probe route now carries the same two paths the dock has — a fresh per-render function on the field's onSubmitEditing prop, and the memoized closure on the binding — because the working prop path is what hid this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the buffered field's page submit reaches a live handleSend Found by pullfrog on #22300. `submitBufferedDraft` was memoized on an empty dependency list, which froze the per-render `handleSend` it calls. That guard reads `client`, `activeHandle` and `canSend`, none of which the first render has, so the page's line-break submit could never pass it. The field's own `onSubmitEditing` prop kept working, which is what hid it. The handler is per-render now, and the binding refreshes its handler ref on every commit rather than when the callback identity changes — the ref exists so the listener always reaches the newest handler, and it should not rest on a caller's memoization. That second half fixes nothing on its own: a `useCallback` with `[]` returns one function object for the life of the component, so no ref can find a newer closure behind it. The source census is what catches that, and it is red against the frozen handler. The probe route is back on the dock's shape, per-render on both submit paths, with a note saying why a route that writes its own submit cannot catch this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's buffered submit reaches a handleSend that can send The census beside this reads one spelling of a frozen handler. This reads the behaviour: the send-actions hook is rendered first as a session is before its effects land, with no client and no handle, then again as it is after, and the listener the page's binding attached has to reach the second one. Asserted on the params that reach the client, not on a call count. Red with the `useCallback` restored, and red with a `useMemo` in its place, which is the point of testing the behaviour rather than the spelling. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): declare the two mock arrays instead of asserting them `[] as Array<T>` inside the hoisted factory was a type assertion with nothing to explain: the arrays are built here, so a checked declaration says the same thing and the quality gate has nothing to flag. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the submit binding stops claiming a cure it does not have The handler ref refreshed on every commit rather than on the callback identity, and the docblock called that the reason the seam exists. It is not: a caller that freezes its closure hands this hook one function object for the life of the component, so no ref finds a newer one, and a caller that does not freeze it changes identity every render and refreshes the dependency anyway. Measured both ways. The dependency is back, and the prose says only what the code does. The rule that does hold — a bound handler must not be frozen on an empty dependency list — is stated where it is enforced, in the wiring census, with the behavioural check named beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the submit binding's latest-ref drops its dependency list React Doctor flags the dependency twice, once per bound field: both of the dock's field submits are rebuilt every render, because the handleSend they read is, so `[onSubmit]` is a new value every time and there is nothing to compare. The tree's other latest-refs are written without a list for the same reason — use-mobile-web-shell-bridge.ts:148 is the one this follows. The comment says what the ref does, which is mirror the newest closure after each commit so callers may pass per-render handlers. It claims nothing about a caller that freezes one; that rule is still the wiring census's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a8786c040d |
fix(mobile): the page never removes a host, and stops bundling push (#22283)
* fix(mobile): the page never removes a host The page holds one host profile from `init.host` and no credential, so `removeHost` on web resolved without doing anything and the screen reported success for a host that was still paired. Its `.web` sibling refuses with a typed error instead, and the auth-failed banner's Remove — the one surface that opens the confirm — is absent on the page, because a control that can only refuse should not be there. Refusing is also the fence that keeps `push-registration.ts` out of the page bundle: the native lifecycle file's import was that subsystem's only path into a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the push families the page closure no longer reaches `host-removal-lifecycle.web.ts` was `src/notifications`'s only path into a page route, so the whole directory left the page bundle and the capability probe left the C1 layout closure with it. The derived family set shrank by two; the pin tables and their counts now match what the closure reaches. The expo-notifications fence grows a second claim and loses a precondition that had become false: `push-token.web.ts` and `desktop-notification-channel.web.ts` are no longer in the bundle either, so the fence is stated as the absence of the directory. C1 20 families / 94 goldens, C2 68 / 257, C3 26 / 116, C5 25 / 125. Session route closure 4211 -> 4207 modules. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page's auth-failed banner offers no control it can honour The banner is reachable on the page — the shell forwards the native client's state verbatim (`bridge-host.ts:381`) and `auth-failed` is in the wire enum (`bridge/bridge-envelope.ts:43`) — and the page can honour none of its three actions. `forceReconnect` is `() => Promise.resolve()` there (`client-context.web.tsx:55`, read through `host-client-hooks.ts:87`), `/pair-scan` sits outside the page's route root of `app/h` (`mobile-web-app-route-manifest.mjs:6`), and removal refuses. The previous commit hid only Remove and claimed the other two still worked; they do not. The whole action row moves into `AuthFailedBannerActions`, whose `.web` sibling renders no control and one line naming the app. The sentence above it is unchanged: re-pairing from the desktop is still what to do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3bb9a4e261 |
fix(mobile): keep a painted frame under the page until its first paint (#22264)
* fix(mobile): keep a painted frame under the page until its first paint The shell tore its own frame down the moment a generation was on screen (`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws nothing until its document paints. What showed for the whole of the page's boot was the surface behind it with nothing on it: 1.42 s on a cached generation, against a one-frame budget. The page is the only thing that knows when it has a frame, so it says so. It declares `painted` in `ready.reports` and posts the notify after the browser has painted its first commit; the shell holds the same neutral frame it was already painting while it opened the generation, then fades it out. The wait is bounded by the declaration and never by a timer: a generation served by an older desktop declares nothing and is uncovered on `ready`, which is what every shell did before this. iOS painted white rather than nothing: a WKWebView is opaque by default, so the shell's own surface never showed through. It is now transparent, as the Android view already was. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the cover through the compositor handover The page reports the paint its own renderer made; putting that on the app's surface costs another frame or two. A linear fade from the report left two frames of bare surface between the two on an emulator, which is the hole the cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity across that handover: five reopens now show 0-21 ms of bare surface against 102-2043 ms on the build without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): negotiate the paint report in both directions The page posted `painted` whatever shell it met, and `notify` is a closed union: every shell installed before this answered it with an error frame, once per mount. The shell now advertises the name in `init.accepts` beside the param clear and the client identity, and the page posts only when it was advertised. The declaration in `ready.reports` stays unconditional, because it is an optional field an older reader strips rather than a new opcode, and because the first `ready` — the only one that matters for the first paint — is sent before any `init` has arrived. The accepts list moves into `bridge-init-frame.ts` beside the grants, which is the module that builds the frame carrying it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cover's colour instead of asserting its shape Two gate findings on the round-two head. The cover test reached the background through a cast of the style prop; it now reads it through a checked narrowing, so the test proves the shape it depends on rather than declaring it. `use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args gained `onPagePainted`: its harness is a literal, so a new required handler is a missing property. The probe now counts paints and one case spends the counter, which is what a handler wired only to satisfy a type would not do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the cached-generation opening out of the reducer `mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused- update work and the paint handling both grew it. What comes out is one thing — putting a generation already on disk on screen, and deciding whether this route is one that bundle carries. It is the reducer's cache path and its refused- update path both, and it was already three functions sitting together. `step` goes into a module of its own because the two now share it; a copy in each would be two spellings of one transition, and exporting it from either would point the dependency the wrong way. No behaviour moves: the reducer's table tests are unchanged and the page closure is unchanged at 4,211, since neither new module is reachable from a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the previous document's paint when a new one starts A document that replaced a painted one inside the same mount inherited its `pagePainted`, so the cover lifted before the replacement had drawn anything. The native view already reports `loading`; the screen dropped it. It now reaches the reducer as `document-started` and clears the page document state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the declaration case call the frame policy The case compared the name to itself and never called `shellPageFrame`, so it passed for a policy that ignored the declaration entirely. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the page's frame from the route screen, not the router Every route screen is behind `import()`, so the wrapper above expo-router commits with a suspense fallback while the chunk is still arriving. The paint report hung there, which uncovered the shell's view over an empty body on a cold chunk. It now hangs on the screen the manifest resolves, layouts excluded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): retire the readiness wait a replaced document armed `document-started` cleared the page document state and left the flow alone, so the previous document's readiness deadline passed the flow check, read `pageReady` as false and failed a session whose replacement was still loading. The flow moves with the document, for the reason `remounted` already moves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a departing route screen take its paint report back The report waits two frames, and nothing cancelled the second one, so a screen unmounted in between still told the shell to uncover. The reporter now answers with a take-back the wrapper returns as its cleanup, and the once-per-document latch frees only when a report was cancelled before it landed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the screen that arrived take over a frame still owed A screen committing inside the two frames an earlier one was owed found the latch taken and reported nothing; the earlier screen then freed that latch on its way out and nobody was left to lift the cover. The newest commit now supersedes the pending report, and only a posted one spends the latch. Covers the redirect window with a render check against the pr route, whose target chunk is held open while the document sits on the hub's fallback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the take-over case turn on the take-over The case cancelled the first screen's frame through the cleanup path, so it passed with the take-over deleted. It now leaves that screen mounted and reads the clock: the frame after the replacement commits is the replacement's first, not the one the screen behind it was still owed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9da7f7edfc |
test(config): the session render rig waits for the reads it asserts (#22269)
The session route issues worktree.show, session.tabs.list and terminal.list from effects that run after the commit painting 'Terminal', so reading __orcaRenderCheckRequests where openRoute resolves is a snapshot taken before the burst. Under a 20x CPU throttle none of the three is in it, which is the shape the loaded CI job hit twice. waitForRecordedRequests polls the double's own log under a 30s bound and names what never arrived. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ba742a86bb |
fix(linux): release orphaned processes when their owner exits (#22247)
* fix(linux): release orphaned processes when their owner exits * fix(linux): handle inhibitor errors until streams close --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
0ab2ba3480 |
fix(mobile): the page stops writing, importing and requesting what it cannot use (#22241)
* fix(mobile): the page keeps no host app-version record `host-status-gates.ts` runs above every page route, and a readable `status.get` had it write `orca:host-app-version:v1:<hostId>` through `host-app-version-store.ts`. Inside the page AsyncStorage is the bridge's adapter and that key is not one `page-storage-keys.ts` hands a route, so every mount posted a write the shell refused and logged as `storage-write-dropped`. Not admitted through the storage seam, because the page never reads it back: the record's only reader is the native troubleshoot screen's `native-diagnostics-operations.ts`, which is not in the page's bundle. A `.web` sibling keeps no record instead. The bounds check moves to `host-app-version.ts` so both hosts read a reported version the same way. The session render check now collects warnings as well as errors and answers `status.get`, which is what arms the write: the other cases' double answers no RPC, so the drop needed a reply rather than a control. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page does not import expo-notifications `DevicePushTokenAutoRegistration.fx` runs at import: it adds a push-token listener React Native Web answers with a warning, and it reads the persisted server registration out of `window.localStorage` behind a `typeof localStorage === 'undefined'` guard. The Android shell's WebView has DOM storage off, where `window.localStorage` is `null` rather than undefined, so the guard passed and the read raised "Cannot read properties of null (reading 'getItem')" at error level on every page load. Two modules imported the package — `push-token.ts` and `desktop-notification-channel.ts`, both reached through `push-registration.ts`, which the host layout pulls in via the host screen's remove action. Both get a `.web` sibling. The page holds no device push token and creates no Android channel; push registration needs a token the shell owns and a gateway the page has no client for. Every call in those two files was already inert on web, so a page that imports one behaves correctly and still loads the package: the closure check beside them is what keeps a third importer out. The session render check adds the device's own shape — `localStorage` reading `null` — and reds on the error the emulator saw. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page declares an icon, so no browser asks for one With none declared a browser asks the origin for /favicon.ico on its own, and the shell's asset server answers 403 because the path is in no manifest — which the emulator run saw repeatedly. The document now carries `<link rel="icon" href="data:," />`, a browser's own way of being told there is no icon. An empty data URI rather than an asset: the page is a WebView document with no tab to put an icon in, and the bundle's images are content-hashed route assets whose names change with their bytes. `img-src 'self' data: https:` already admits the scheme. Two assertions, because each is blind where the other sees. The build check reads the document and runs everywhere. The session render check reads the request, which only a full Chrome makes — `ORCA_MOBILE_WEB_RENDER_BROWSER`, what CI resolves — and reads it off the server's own log: a favicon fetch comes from the browser process rather than the page, so Playwright's request events never report one. It also settles on network idle first, because the fetch comes after the text the route waited on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): trim the page-noise comments to the bar Comment-only. The three `.web` siblings, the document's icon line and the three override reasons each said their cause once and then said it again; each now states what the page keeps and why, once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the session closure after expo-notifications left Measured on this head with all five generators run first, against a scratch worktree detached at the base, which reads the committed pin exactly: 4271 modules and 1023 local. modules 4271 -> 4210 (-61) local modules 1023 -> 1024 (+1) 65 modules leave and 4 join. 62 of the 65 are vendored: expo-notifications' own 55, and expo-application, badgin, abort-controller and event-target-shim behind them. The other three are the native files the `.web` siblings replace, so the siblings cost the local count nothing and its +1 is `host-app-version.ts`, the one new module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): count the packages the closure note names The note said 62 vendored modules left and then named five packages without counts, so the names read as the whole of the 62 and summed to five. Each carries its own count now: 55 + 3 + 2 + 1 + 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b76bc79d73 |
build(mobile): key Metro's transform cache on the shell build kind (#22244)
`babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into `mobileShellBuildKind` at transform time (babel-preset-expo/build/inline-env-vars.js:51), but nothing Metro hashes into the transform cache key carries that value: the key is `metro/src/DeltaBundler/getTransformCacheKey.js:21`, whose inputs are the Metro version, `cacheVersion`, the transformer path and `@expo/metro-config/build/transform-worker/metro-transform-worker.js:600`, none of which reads the environment. A release assembled after an opposite-kind build reuses the warm entries and bakes the wrong shell, and the absence of the variable's name in the bundle cannot tell the two apart. The newest published `@expo/metro-config` (58.0.4) keys it no differently. Folds the kind into `cacheVersion`, by the same `=== 'ota'` rule the app applies, keeping Metro's own version as the prefix. Proven with four `expo export --platform android` runs against an isolated Metro cache. Before: `ota` then `native` produced byte-identical bundles, both `return 'ota'`. After: the second run returns `'native'` under a different bundle hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
632ae1320b |
fix(daemon): reap terminal descendants during shutdown (#22232)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
59c0d5585e |
fix(mobile): the shell swaps the page's client identity so its terminal reaches init (#22201)
* fix(mobile): give the page a client identity so its terminal reaches init
The page's RPC provider answered `getClientId` with `null`, and the session
route refuses `terminal.subscribe` without a client identity. No subscribe
meant no scrollback, so `init` never reached the terminal document: the
surface stayed 0x0 and the document answered every measure with `notReady`.
`canSend` reads the same value, so live input was dead for the same reason.
The identity is the page's shell session, not the pairing credential `init`
deliberately withholds: the host only ever uses `client.id` as an opaque
in-memory key for the mobile input floor and the viewport claim.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): the shell swaps the page's client id for the device's
The host does not read `client.id` as an opaque key: `terminal.send` refuses a
query reply whose id is not the credential the socket authenticated with, and
`terminal-send.test.ts` pins that as a spoof. So a page-chosen id was wrong on
that path however stable it was, and the session id
|
||
|
|
da1c322b00 |
feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in preferences.ts. Expo's babel preset inlines a literal process.env member expression at build time, so a release bundle carries the answer as a constant and anything but the exact string 'ota' — unset, empty, a typo — is native. Every default build is therefore the native app, unchanged. mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to mount the page comes from the build and never from storage: a native binary installed over an OTA one, same bundle id and same data container, still refuses a stored 'true' without reading the key. An unset key reads on only in an OTA build; a development build keeps its opt-in, and a stored 'false' wins everywhere so the Troubleshoot toggle can switch an OTA build back to native. That toggle now mounts wherever the flag can be on, which is the only way back to the native screens in an OTA build, and its label names the build kind rather than saying "(dev)". The bundle probe row beside it stays development-only: it fetches. The flag census gains two rules — one module reads the switch, in the member form Expo inlines and not the bracket form, and one named function answers the build kind — and the build-kind fence now lists the Troubleshoot route that asks it. Docblocks that said a store build can never mount the shell now say it mounts only when built for OTA. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): one workflow input picks the shell, and no input means native Both release workflows gain a `shell` workflow_dispatch choice, options native and ota, default native, and hand it to the step that bundles the JavaScript as EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and the fastlane build_and_upload step on iOS; nothing else in either file sets it. A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'` yields native for them — the first OTA release is a dispatch with one field changed, and every other run is the app we ship today. Each build step prints the value it is about to build with, read back from the same variable rather than from a second copy of the expression, so a run's log cannot claim a shell the build did not use. The new contract test evaluates that expression rather than matching its text: absent, empty and 'native' all resolve to native, 'ota' to ota, and any expression shape it cannot evaluate is a failure rather than a pass. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build: the desktop packages the real page, and the placeholder is retired build:mobile-web now runs the app builder and app verifier, and both take their output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each carrying a constant of their own — one definition of where the bundle lives, so a drift cannot leave electron-builder's beforePack looking at an empty directory while the builder reports a tree it wrote elsewhere. build:mobile-web:app is gone; it was the same two commands. src/mobile-web/ and its two scripts go with it. What the app builder shared with them is split into three modules named for what they hold rather than for the bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the canonical asset serialization, buildId, hashed assets, the protocol window and the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two failure modes are Windows paths and symlinked entries), and mobile-web-source-line-endings.mjs (the CRLF guard, now with a required directory rather than a default pointing at the deleted tree). The two suites that only needed *a* valid tree on disk — the beforePack guard and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree instead of bundling the whole mobile graph. It goes through the same manifest writer the page does, so a manifest shape change still reaches them. Also retired: the placeholder's tsconfig project and its typecheck lane, its knip entry, its electron-builder exclusion and .gitattributes pins, and the app-bundle test that asserted the shims stayed out of a builder that no longer exists. pr.yml's page job builds the same bundle the package job ships. Inert for native phones: they never fetch it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(config): one import of node:fs/promises in the entry-detection suite The changed-code quality gate's focused plugins read the two as a duplicate import; the readFile line was left over from the split. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs: the comments that still describe the retired placeholder bundle The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app and shipped by nothing. That script, that directory and that fact are all gone: it is built by `build:mobile-web` into the packaged bundle dir, and a phone mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota. Two Windows cache keys explained themselves by naming src/mobile-web and "the two bundle builders"; config/** now covers the builder, the verifier and the manifest writer, and the spike's key no longer waits on a Phase C flip that has happened. The keys themselves are unchanged. Three scratch directories in the app-bundle suites and one in the verifier still spelled the retired output root. Renamed to mobile-web, which is what the build writes; they are temp subdirectory names and nothing reads them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9a25e318f6 |
fix(mobile): write live-input text through a seam the page can honour (#22189)
* fix(mobile): write live-input text through a seam the page can honour On React Native Web a TextInput ref is the DOM node itself, so `setNativeProps` does not exist. The two terminal live-input hooks called it on `liveInputRef.current`, and the session route reaches one of them from a mount effect, so the write was a TypeError that faulted the whole page: the shell tore the view out, the terminal surface stayed 0x0, the IME never opened and the accessory bar did nothing. Both now write through `terminal-live-input-text-write.ts`, whose `.web.ts` sibling sets `value` on the `<input>` or `<textarea>` RN Web renders. That covers the case React has no commit to make, which is where an interrupted IME composition leaves the field. The browser oracle is new: the session render check cannot reach this defect, because its shell double answers no RPC, so no terminal handle exists, `liveInputEnabled` is false, the field never mounts and the write is skipped by its own optional chain. The new check mounts both hooks against a real field on the page bundle instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): fence every setNativeProps write with a web sibling Fenced by absence rather than by a list of approved callers: any mobile source module that calls `setNativeProps` must ship a `.web.ts(x)` for the page bundler to resolve in its place, and no web sibling may make the call itself. Run against the two hooks as they were, it names both. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): trim the live-input seam's comments to what is not obvious Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the settings scroll lock into a seam, and fence the route tree The census scanned `mobile/src` only, so it read the terminal-settings screen's `setNativeProps({ scrollEnabled })` as absent. It scans `mobile/app` too now, under a stricter rule: a route file may not make the call at all, because expo-router registers a `.web.tsx` beside a route as a second route rather than as a platform sibling, so a route cannot own a platform split. The one offender it named moves into `src/terminal/terminal-settings-scroll-lock.ts`, beside the styles module that already owns this screen. Measured rather than assumed: RN Web puts an `HTMLDivElement` in the ScrollView ref with no `setNativeProps` at all, and drives the scroller's overflow from a generated class with nothing inline, so the sibling locks with an inline `overflowY` and unlocks by clearing it. `overflowY` rather than `touch-action`, which stops a touch drag but leaves the wheel scrolling. The seam does not enter the session route's closure — terminal-settings is not under `app/h` — so the module pin is unmoved at 4270, re-measured. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the absent setNativeProps with `in`, not Reflect.get `in` walks the prototype chain, so the claim that RN Web has no such method to inherit is the same one, and the anti-slop gate has no dynamic read to object to. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
6ae5ef2d00 |
fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) (#22182)
* fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) `nodePaths` is a fallback esbuild consults only where normal resolution fails, so it never reached the four modules under `src/shared` that the page imports: sitting above `mobile/`, their bare `zod` resolved upward to the root's 4.5.4 while the 58 mobile modules beside them resolved to mobile's 4.4.3. Both shipped -- 808,470 bytes of duplicate source, and salvage combinators built by one instance nested inside schemas built by the other. Mobile's copy, because the mobile app already says so: `mobile/tsconfig .json` maps `zod` to `./node_modules/zod`, a shared module joins that program as an imported file, and `--traceResolution` shows tsc holding `zod-salvage.ts` to 4.4.3 today. The bundler was the only layer that disagreed with the app's own compile-time contract. The build drops from 67 scripts to 66 and from 8,055,568 bytes to 7,686,714, nearly all of it before the first route: the entry's static closure falls from 1,612,253 to 1,244,312. So the C7.8 sweep is re-measured rather than bumped, and the entry-budget note's static-import readings are re-measured with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-measure the pins the second Zod was inside The session route's module closure and the asset ceiling both counted the root's copy. Both re-measured rather than adjusted to fit. The closure falls 4363 -> 4269. The two module lists were diffed rather than the total inferred: 94 entries gone, every one of them `zod@4.5.4`, none added, because mobile's 79 were already in the closure, and the `local` count holds at 1021 -- this took no source module out of the page, only the second copy of a package. The asset ceiling is derived from the chunk envelope, which the re-measured sweep moved by one, so 30 routes now derive 248 assets and 31 derive 257. The crossing it exists to name is still 31. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): align three readings with the head that measures them All three described a bundle with two Zods in it, and the third was already stale on main. The escape-hatch note said 5 of the 14 routes break the entry budget on their own. Re-measured by making one route's manifest entry a static import and reading the entry's own static closure back, it is one: session at 3.32 MiB, with five more between 1.85 and 2.17 of the 3 MiB. Same readings as the note in verify-mobile-web-app-bundle.mjs, which is the thing this case asserts against. The mermaid pair said the bundle "emits 69 scripts". It emits 66, and this head's own fourteen-route prefix reads 64. The pair stays at 69 and 172: what the case pins is that the envelope tells the two apart, not either build's size. Saying so in the comment, with the warning that 69 now sits just under the envelope -- a sweep that falls further fails this on a frozen number, which is a signal to re-measure the pair rather than to raise the ceiling. The assets line said 215 against 112. 215 still derives from the pinned 172; 112 never matched the envelope it claimed, which allowed 114 on main and allows 113 here. The route count is the one number here that has to follow the tree, so it is spelled and pinned to the sweep's own length. The static-import readings are measurements rather than table counts and the mermaid pair must not move at all, so neither is spelled. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cut the mermaid and census comments to their claims Round 2 explained the frozen pair over four paragraphs and the census row over six lines. Both now say what they are for and stop: the pair, why neither number moves, and the one warning that matters. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a932147308 |
test(config): the render rig's navigation wait is bounded, and the frame-budget sweep reads the frames its capture painted (#22177)
* test(config): the render rig's navigation wait is bounded and says why it gave up `waitForRecordedNavigation` ticked every 10 ms until the case's own timeout, so an arm whose click missed its 2 s actionability window waited for a record nobody would write and failed as a bare timeout with the click's error swallowed. Measured on this tree: with the click pointed at a selector that does not exist, both engines failed with `Test timed out in 120000ms` and no mention of the click. C8.1 round 1 saw the same shape at 240 s on CI and dropped a render arm for it. The bound is 10 s, sized from the rig rather than chosen: the four arms that take this path, three runs each on both engines, answered on the loop's first check at 0 ms in all 24 readings, and in 24 more taken while two full `config/scripts` suites ran beside them, where the slowest whole case was 2859 ms. Past it the wait throws naming the arm, how long it waited, what the click did and what the frame last read; the same case now fails in 14.4 s. The happy path is unchanged -- the first check still answers it, with no added wait. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): the frame-budget sweep reads the frames its own capture painted The sweep decided which screencast frames carried the noise by counting arrivals after the raster barrier, and frames do not reach the client in capture order. Measured directly against Chromium through CDP at 1400x1600, six captures at 20x CPU throttling: every frame of the black canvas the resize left, and every frame still in flight from the previous viewport, was stamped 86 to 161 ms before this capture's paint and still arrived after the barrier, while every frame carrying the noise was stamped inside 150 ms after it. A black 1400x1600 frame encodes to 13483 bytes, which is the 0.006 bytes/px read on 2026-09-22, and a full frame of a previous smaller viewport is the ~447 KB whose posted envelope was the 596462 that 2026-09-21 expected to be null. So the precondition is a reading rather than an ordering: the paint hands back the page's own clock, `Page.screencastFrame` carries the browser's capture time on that same clock, and only frames stamped at or after the paint are admitted. A frame with no capture time is not admissible either, since it cannot be told from a stale one. When none is admissible the error prints every frame with how long after the paint it was captured, instead of understating the budget in silence. The healthy reading is unchanged -- 0.55296 max, 0.54399 min over three idle runs and three under two concurrent config/scripts suites, against 0.55296 / 0.54399 before -- and so is the cost: 18.4 s against 18.1 s. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
48bdbb8e24 |
refactor(mobile): the host-scoping rewrite moves out of the terminal's HTML (#22172)
`scopeDocumentStyleToHost` and `scopeStyleToHost` are one rewrite of a flat stylesheet, and two page mounts read it: the terminal's and the rich Markdown editor's. The module lived under `terminal/terminal-webview-html/`, so the editor reached across the terminal's directory for it. It moves to `src/style-scoping/`, named for what it does rather than for its first caller, and both mounts import it from there. No re-export shim: the old path is gone. Its test does not follow it whole. Four of its five cases read the terminal's own sheets (`TERMINAL_DOCUMENT_*`, `XTERM_ENGINE_CSS`) and the first asserts `document-style.ts`'s split identity, which is not about the rewrite at all -- so that file stays in the terminal directory as `document-style.test.ts`, beside the module it is about. What moves is the part that names no caller: which selectors read as the document's own, and the sheet shapes both exports refuse. The page-closure pin names the module by path and is updated in the same commit. The closure is otherwise unmoved: 4,363 modules and 1,021 local before and after, one path swapped for another. The five generated artifacts are byte-identical -- none of them bundles this module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
895f2cf477 |
test(mobile): the web app's script fence is re-derived from a measured sweep (OTA phase C, C7.8) (#22152)
* fix(mobile): re-derive the web app script fence from the measured spread (OTA phase C, C7.8) `4r + 16` was a guess at break-even, and its slack ran from 17 scripts at one route to 4 at thirteen -- loosest where nothing is and tightest where the tree actually sits. Re-measured by building every prefix of the sorted route key list: 15 routes emit 67 scripts, and the marginal cost of a route runs 1 to 9 depending on what it shares, so no line through the route count is both an upper bound and a budget. The sweep is now the fence's only input. The envelope is the measurement plus one margin at the swept tree, growing by the worst route the sweep saw for every route past it, so a new route breaches it only by costing more than any route measured. The margin is four, which is the most the count has been seen to move at a fixed route count with no route added: the head that wrote the old fence read 32, 43, 61 and 69 at 8, 10, 12 and 14 routes where this one reads 34, 44, 57 and 65. Two-sided in the test, which is what stops the next bump: a build more than the margin under the envelope fails there too, so the fence has to be re-measured rather than raised. The route count stays the only term and the mermaid control still tells one artifact from 172 chunks. The shell-fit crossing comes in from 50 routes to 31 with it, because the envelope grants the worst swept route where `4r + 16` granted four. Byte budgets re-read from this build and unchanged: 8,053,438 of 9,437,184 total, 1,612,006 before the first route of the 3 MiB allowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): spell the session route's grant counts off the table (OTA phase C, C7.8) #22072 deleted `native.wakelock.set` from the session route and left both prose counts behind: "Fourteen grants" for a list of thirteen, and "the four audio verbs" for the three that remain. The all-or-none reason went stale with them -- it argued from a screen free to lock, which is the verb that was removed, and the device side has owned that lock since. It now argues from the microphone a route granted two of the three cannot close. The census beside the route-declaration test is what stops the next one. It reads which number words appear before "grants", "audio verbs", "media verbs" and "or none" anywhere in the file and compares them with the list itself, so a second spelling left in place fails rather than passing on the first correct hit, and a comment rewrapped at a different column still matches. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the four sibling comments spell their grant counts off the tables too (round 1) #22072 removed `native.wakelock.set` from the session route and left the count standing in four more files than the route table: "fourteen grants" in the call-site census and in the hop census, "the four audio grants" in the census and its test, and "the four audio verbs" on the bridge schemas. Each is now counted off the table it describes rather than copied beside it. The six-versus-eight split in the call-site census could not be re-derived -- its own "those eight" never summed to the six rows plus the audio grants, so it was wrong before #22072 too. It is replaced by what the tables say today: six rows pin eight of the session route's thirteen grants, and the other five have censuses of their own. Both numbers come from `PAGE_GRANT_CALL_SITES` and the route list, and the existing case that names six rows covering eight grants is what holds them. The census the route table got is now `spelled-count-census.mjs`, driven from three files instead of one. A phrase restated in a file collapses to one claim, so a header and a test name may spell the same count; a phrase with no number before it reads as the empty list, which no table count matches. The bridge test is the one site with no count left to pin: it names `BRIDGE_NATIVE_VERB_NAMES` instead, and a new assertion holds its own `AUDIO_VERBS` equal to that table's audio rows, so the cases below cannot pin one set while reading as coverage of another. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the session sentence off the session intersection (round 2) `'of the session'` was counted off every grant in `PAGE_GRANT_CALL_SITES`, which is not what the sentence it pins is about. A row pinning a grant no route declares -- the shell can serve a verb before a screen asks for it -- would have made the census demand the comment overstate what the session route has. Proved before fixing: adding `native.share.send` to the navigate row moved `pinnedHere` from eight to nine while the session intersection stayed at eight, and the census failed asking for "nine of the session route's thirteen grants" against a sentence that was right to say eight. With the intersection it passes under the same probe, and the failure moves to `'grants this file pins'`, which is about this file's rows and does correctly demand nine. The other three rows are left as they were, for the same reason read the other way: `'grants this file pins'` and `'did not'` are about the rows here, so they keep the full list, and `'have censuses of their own'` and `'are not repeated here'` already count the session grants no row pins. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the two counts in this file's it titles too (round 3) The census pinned the JSDoc counts and stopped there, so "names six rows covering eight grants" and "reaches every one of the eight through the session route" were free to go stale. Shown rather than argued: with a ninth grant on a row and the header corrected to nine the way the old census forced, the suite went green with both titles still saying eight. `'grants this file pins'` becomes `'grants'`, which reads the header and that title as one claim -- they are the same number, and a row per site would have let them disagree while both passed. The session title takes the intersection, for the reason round 2 gave. Every other spelled number in the file is not a count of a table: "the two tables" is how many sources the census reads, and "the one it reads", "a new one cannot be missed", "any one grant went missing" and "every one of" are quantifiers with no table behind them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the session-route title off the rows it asserts over (round 4) `'through the session route'` took the intersection, but the title it pins heads an assertion that compares `grantsNeeded` with every row's grants. The title names the session route and is not a claim about it: it says the rows here are all reached through that route, so it moves when the rows move. In the divergence the JSDoc already described, the two parted. With a ninth grant on a row the assertion compares nine while the census held the title at eight and passed, leaving a title reading below the assertion under it. Exactly one row takes the intersection now, the `.mjs` sentence for how many of the session route's grants these rows cover, and the JSDoc says so rather than describing a rule with two members. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): two wording fixes, and the byte readings re-measured on the merge (round 5) pullfrog on the census JSDoc, both correct. "make the census demand overstates" was a finite verb where the bare infinitive belongs. "Every other row counts the rows here" was contradicted by its own table: the rows reading the route's list and the rows counting grants no row pins take neither count, so the sentence is scoped to the rows that choose between the two and says what the rest read. The two byte readings in the fence doc are re-measured on the merged tree, since they name a head: 8,055,568 of 9,437,184 total and 1,612,253 before the first route. C8.1 added no route, so the sweep and the envelope are untouched and the tree still builds 15 routes into 67 scripts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e47ef8cc28 |
feat(mobile): the shell tells a page which optional capabilities it has (OTA phase D, C8.1) (#22141)
* fix(mobile): publish page-route pairs the strict host schema accepts (OTA phase D, C8.1) `routeViewOf` handed the manifest's own route entries to the host as `pageRouteGrants`. The phone reads a manifest route loosely, so an entry arrives carrying whatever field the desktop that wrote it knew about, and `BridgePageRouteGrantsSchema` is `.strict()`: one unread key refuses the pairs, `createBridgeHost` refuses the route with them, and the page gets no `init` at all rather than losing one field. Fixed before any route carries an optional grant (ruling 37.4), so the manifest field the next commits add costs an installed shell nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: drop the closure and bundle probe scripts from the tree Scratch measurements for C8.1 (which route closures reach the HTML preview, and what the preview render rig costs to bundle with a client provider). They belong outside the repository and were swept in by the previous commit's `git add -A`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): a manifest route may declare optional grants (OTA phase D, C8.1) Design B of design-ota-c8-1.md, ruling 37. `MobileWebBundleRouteSchema` grows `optionalGrants` under the required lane's own grammar, with the 16-name ceiling applied over the union of the two lists rather than to each. Serving a route still reads `grants` alone, so a capability a screen cannot work without stays required and takes the route native; a session's granted list is `[...grants, ...optionalGrants]` narrowed to what this shell implements, from one helper that both `grantsForRoute` and the `pageRouteGrants` publish read. The ruling's compatibility rationale is corrected in place. `z.looseObject` passes unknown members through rather than dropping them (measured, zod 4.4.3), so a shell older than the field still receives the key; what it lacks is a policy that reads one. What makes the lane safe against such a shell is therefore the previous commit's publish fix, not the reader. BRIDGE_PROTOCOL_VERSION stays 1. No new notify, verb or frame field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name the shell's cancelled-navigation behaviour as a grant (OTA phase D, C8.1) `externalNavigation` joins `MOBILE_WEB_SHELL_GRANTS` beside `screencastBinary` and `haptics`, declared in `cancelled-navigation-target.ts` because that is the module holding the rule which acts on it. A third token that is neither a verb nor a notify: the page posts nothing to make a cancelled top-frame navigation happen, so this list is the only thing that can tell a page whether a tap inside the sealed HTML-preview frame escapes at all. A constant and not a platform read (ruling 37.1): both engines dispatch the event, `ios/MobileWebShellView.swift:481` and Android's `MobileWebShellView.kt:382`, so an app build carries the behaviour on both or on neither. The policy census grows the half that was only pinned by the verb table: the implemented set is that table plus exactly three non-verb tokens, each read off the module that declares it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the bundle builder carries a route's optional grants (OTA phase D, C8.1) `resolveMobileWebPageRoutes` maps each declaration member by member, so a field the declaration grows reaches a phone only once the map names it: until now `optionalGrants` would have been dropped in silence and every route would have declared nothing optional. Omitted when the route declares none, because absent and empty are the same answer to a shell. The declaration suite grows the rule rather than a row: the map carries the lane through and writes no key without one, and the lane is held to the manifest's own grammar and to the ceiling over the union of the two lists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the HTML preview hides its links on a shell that cannot open one (OTA phase D, C8.1) The session route declares `externalNavigation` on the optional lane, and the preview asks for it before it renders an artifact's links as links. Ruling 37.2's three readings are what "hide" means here, and removing `href` is what delivers all three at once: `a:any-link` stops matching, so the UA stylesheet stops underlining, the element leaves the tab order, and there is no dead anchor a tap does nothing on. The text the author wrote stays where it was, the artifact paints, and the Preview/Source toggle is untouched. Done with the browser's own parser rather than over the source text: an `href` inside a comment or a `<template>` is text to a browser, and a pass that rewrote either would be editing the artifact instead of its links. The frame also loses `allow-top-navigation-by-user-activation` on that path, so a link the pass somehow missed is refused by the browsing context as well. One route, measured rather than assumed: the design said two, and the file preview route's closure does not reach the HTML preview at all - it renders `MobileFilePreviewScreen`. The new closure census derives that list from the hook's callers. The render rig grows the case on both engines and the readings it needs, and `mobile-web-app-preview-frame-readings.mjs` is split out of it at the readings/arms boundary, because the two were over the 600-line cap together. Two engine findings are recorded in the rig: an `<a>` with no `href` still answers `tabIndex` 0 on both, so focusability is asked by focusing; and WebKit computes `cursor: auto` for a real link, so that reading is pinned where it discriminates and its blindness pinned where it does not. The hop-coverage census now reads the effective set, because that is what the running rule compares. Inert today: the session route is the only declarer and an opener into every other route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's hidden link path where the unit suite can reach it (OTA phase D, C8.1) The mobile suite runs in a `node` environment whose resolver has no `.web` precedence, so `MobileHtmlPreview.web.tsx`'s import of the grant hook lands on the native sibling, which answers yes unconditionally. That is why the existing component suite still measured the granted frame without knowing a grant exists, and it means the hidden path had no coverage in the sharded `test` job, where the render rig is skipped for want of the bundler's dependencies. So the wiring gets its own file with the module replaced: that the component asks, and that both the frame's sandbox and the document it is handed follow the one answer. happy-dom rather than the suite default, because the inerting pass parses with the browser's own `DOMParser`. `String(node.type)` rather than a literal comparison: `node.type` is `ElementType`, which overlaps a real intrinsic tag and not the host strings these mocks render, so `=== 'Pressable'` is a no-overlap error under `tsconfig.test.json` and the tests-typecheck ratchet reds on it. Also replaces a `Reflect.get` the anti-slop gate refuses with an `in` check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session page closure at 4,362 for C8.1's three modules Measured on both sides with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `841d06a969` with all five postinstall generators run first, and the two `local` lists diffed rather than the total inferred: 4,359 -> 4,362 modules, 1,017 -> 1,020 local. All three are local source modules and none is vendored: the page's read of `init.grants.native`, the pass that turns an artifact's links back into text without the grant, and the module declaring the token beside the rule that acts on it - reached both by that hook and by `page-route-policy.ts`. The `bridge-caps.ts` it imports was already in this closure, and the hook's native sibling is replaced rather than joined. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): allowlist the preview's grant sibling among the .web.* overrides `mobile-web-app-web-overrides.test.mjs` pins the allowlist against the `.web.*` files on disk, so a new web sibling reds it until the file says why the page needs one. Red before: `expected [ …(36) ] to deeply equal [ …(37) ]`, naming `src/components/use-html-preview-link-grant.web.ts`. The preview's own entry is corrected with it: its reason said `allow-top-navigation-by-user-activation` is granted, and that token is now conditional on the shell answering that it can open such a navigation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the hidden-link render case waits on the frame's own reading (round 1) CI's chromium arm timed out at the full 240 s on this case alone while the WebKit sibling passed in 1.5 s and it passed 26/26 locally. The cause is the third arm: it tapped the granted link and waited through `expectNavigation: 'main-frame'`, and `waitForRecordedNavigation` has no bound but the case's own timeout. Under CI load the click missed its 2 s actionability window, no navigation was ever recorded, and the arm sat in that wait until vitest gave up - `recorded []`, with the frame attached only at 38.9 s. Three arms sharing one budget is what made this the case to find it. The arm is dropped rather than its wait lengthened or retried. Every verdict left is a reading the frame itself publishes: the anchors its document holds, the style the engine computed for one, whether focus lands on it, and now whether the tap this arm made landed at all - `actError` is asserted null, so a click that never reached its target is no longer the same three zeros as a tap that did nothing. Nothing is lost. The tap's outcome on a granted shell is the next case, on these same counters from this same rig and with a budget of its own, which is the presence precondition this file already uses elsewhere for the same reason. The WebKit sibling's discriminating reads are untouched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the inert-link pass changes nothing an engine renders but the links (round 2) Round 2's ruling: the hidden-link path may change nothing about the artifact's rendering except that links are not links. A parse and a reserialise is not free of that by default, and all four findings reproduced on Chromium 147 and WebKit 26.4. A same-document fragment link is kept. It starts no navigation at all, so it goes on working inside the sealed frame whatever the shell can do, and taking it away would be degradation over a capability it never needed - an artifact's own table of contents is the case. Its `target` still goes, because a fragment aimed at another frame is a navigation rather than a scroll, and `href=""` is not a fragment: it resolves to the frame's own URL. Links inside `template.content` are reached, recursively. `<template shadowrootmode>` is a declarative shadow root the frame's parser attaches and renders, and `querySelectorAll` does not walk into template content, so those links arrived live inside a sandbox that refuses their navigation - the dead anchor ruling 37.2 forbids. Measured: `parseFromString` attaches no such root on either engine or in happy-dom, so the pass can reach them. The leading newline of a `pre`, `listing` or `textarea` is written back. A parser drops one after the start tag and the serialiser is specified to put it back; measured, neither engine's does, so a round trip lost a blank line from every such block. The doctype is carried whole, and the reason is corrected from the one the finding gave. It cannot move this frame between layout modes: a `srcdoc` document takes its mode from its embedder, and measured, a quirks doctype, the bare name and no doctype at all all read `CSS1Compat` inside the frame. What rewriting it does is change the document the author wrote for no reason, with `document.doctype` observable beside a Source tab showing the original. The render case pins `compatMode` as the blind reading it is and reads the frame's own doctype identifiers as the one that discriminates. Option B was not available: the frame has no `allow-scripts` and inherits `script-src 'self'`, so nothing runs inside it and there is no injection to carry the work. Also drops a vacuous half of the affordance test. `renderSource()` is called with no argument, so the markup a Source view shows is the caller's own closure and asserting it equals the fixture passed whatever the component did. What the component decides is whether the rewritten frame stays mounted underneath, and that is what is read now. `mobile-web-app-preview-arm-driver.mjs` is split out of the render rig at the boundary the readings module already names - the rig holds what each case claims, the driver how an arm is driven, the readings what it reports - since the three were over the 600-line cap together. No max-lines disable or bump. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a fragment link is a frame navigation in this preview, so it is inerted too (round 3) pullfrog is right, reproduced on both engines before believing it. Round 2 kept `#`-prefixed hrefs on the theory that they are same-document scrolls. In this frame they are not: the document's URL is `about:srcdoc` while its base URL is inherited from the embedder, so `#section` resolves against the shell's own URL and the destination differs from the document's by more than a fragment - which makes activating it a frame navigation, and the shipped `frame-src 'none'` refuses it. Measured under the shipped policy, one tap, with something to scroll: Chromium 147 scrollY 0, frame becomes chrome-error://chromewebdata/, artifact gone, embedder reports frame-src <origin>/preview WebKit 26.4 scrollY 0, frame stays about:srcdoc and intact, same report So the destruction is Chromium-only but the absence of a scroll is not: there was no working affordance to carve out for, and the carve-out left a live link that destroys the preview - worse than the inert text it was meant to avoid. Both sandbox values behave the same, so this is the base URL and the policy rather than the sandbox. The same tap does the same thing on the granted path, where this pass does not run, so an artifact's internal links have never worked in the preview. That is not this change's to fix; it is recorded in `followup-html-preview-fragment-links.md`, and the render case reads the granted arm's violation as its presence precondition so the behaviour is pinned rather than merely known. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
35897da0aa |
fix(mobile): serialize a list the engine nested inside a paragraph (#22145)
`insertUnorderedList` puts the `<ul>` inside the `<p>` it was given rather than replacing it — measured on WebKit 26.4 and Chromium 147 both — and `blockMarkdown` read such a paragraph inline. A bullet list the user typed came back as the paragraph's own text with no marker, so it did not survive a markdown round trip, on the page and in the native WebView alike. The serializer now reads structure wherever the list sits: text before it is a paragraph, the list is a list, text after is a paragraph. The DOM is left as the engine made it and no branch asks which engine is running. The parse side needs no mirror — it already renders `- x` as a top-level `<ul>`, which is the shape the fixed serializer reports, and the flat control case pins that. The unit fixture is built through the paragraph's own `innerHTML`: the HTML parser closes a `<p>` before a `<ul>`, so a markup string on the editor gives two siblings and would measure the flat shape. Each case asserts the nesting it got before it reads anything. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9243073b73 |
test(mobile): repin the session page closure at 4,360 after #21705 reached it (#22135)
#21705 added agent-session-option-catalog-antigravity.ts to the option catalog the session page reaches. It merged beside C2 (#22099), whose pin of 4,359 was measured before it, so main reads one short. Measured at |
||
|
|
841d06a969 |
feat(mobile): the rich Markdown editor mounts on the page (OTA phase C, C7.10 C2) (#22099)
* feat(mobile): the editor document reads its surface from its host's root The markup gives the editable surface an id, and inside the WebView that is unambiguous because the document is the page. On the page it is not: a stack transition keeps the outgoing session screen mounted while the incoming one starts, so two hosts carry `#editor` at once and a page-wide `getElementById` hands both documents the first one. The seventh seam is the root, exactly as it is the terminal's ninth (ruling 24): the WebView names none of them and gets the whole page, the page names the element its mount planted the markup in. Red first, `vitest run src/components/rich-markdown/document-host-root.test.ts` against the page-wide read: 4 failed, 1 passed — content written into the second host landed in the first, both documents serialized the first surface, an edit in the second reported through the first document's host, and stopping the first took the listeners off the surface the second was still using. The one that passed is the control: a document with no root still reads the whole page, which is what the WebView gets. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): hold the editor's document rules under its host element The editor's sheet says `:root`, `*`, `html` and `body` because inside the WebView it owns the page. Appended to the head of a React Native Web application all four restyle every screen the shell can show, so the page mount may inject only what it owns — ruling 19's rule for `window.onerror`, applied to CSS. The terminal's half of the scoper drops those rules and repaints through a seam, because the colour `html, body` was setting belongs to the application. The editor has no such seam and needs none: its host element *is* that editor's page, so `scopeDocumentStyleToHost` moves the document's own rules onto the host — the variables every other rule reads, the surface colour, the font, the box model — and everything else hangs under it. A selector that merely starts at the document (`body p`) throws rather than being rewritten into something it did not say. Red first, `vitest run src/components/rich-markdown/page-stylesheet.test.ts`: 6 failed, 0 passed, all on the absent export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the fifteen toolbar commands are one row both surfaces render The row of controls is not the WebView's: a press becomes an injected `runCommand` there and a call on the page, and neither difference belongs in the toolbar. Extracted so the page's editor does not declare fifteen rows of its own that would drift from the phone's. `MobileRichMarkdownToolbar.test.tsx` adds the fence a second copy would have needed: the row names every command in the contract, exactly once. Verified red by dropping `codeBlock` from the row — "names every command in the contract, once" failed on the 14-member list before the case went back. The native component's own test and the web fallbacks file stay green unchanged, which is what says the extraction moved nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the toolbar test inside the tests-typecheck ratchet `check-tests-typecheck-ratchet.mjs` reported the new file as newly failing `tsc -p tsconfig.test.json`: the `ScrollView` mock's spread did not match any `createElement` overload, and comparing a node's `ElementType` against the string `'Pressable'` is a no-overlap comparison. Host strings for the mock and `String(node.type)` for the read, rather than a cast. Ratchet back to OK at 800 files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich Markdown editor mounts on the page `react-native-webview` renders nothing in a browser, so C7.6 gave the page a plain Markdown field and recorded the toolbar and the rendered view as a degradation. Ruling 26 makes that debt rather than done: the page mounts the document itself. `rich-markdown-web-document-mount.ts` is the editor's half of what `terminal-web-document-mount.ts` does for the terminal — the sheet held under the host's class, the markup planted in the host, one factory call, and a dispose that gives the host back. `MobileRichMarkdownEditor.web.tsx` is the component over it, with the same fifteen-command toolbar and the same controller the phone uses, so `MarkdownReader` cannot tell which sibling it has. Three seams are the page's rather than the window's. Messages reach `handleMessage` directly and never `window.ReactNativeWebView`, which on the page is the shell's bridge. The URL for Link and Image comes from `TextInputModal`: `window.prompt` was measured to return null in both shells, so those two commands silently did nothing. And no inset source is supplied, so `onKeyboardInsetChange` is never called — the screen's `keyboard-occlusion.web.ts` measures the same viewport with the same formula, and a report here would lift its bar twice. Red first, two runs. `rich-markdown-web-document-mount.test.ts`: 9 failed on the absent module, and its listener case is the one that holds ruling 21 — a second mount reports its own edits and the first mount's detached surface reports nothing, with an event dispatched on it to say so. The four new cases in `mobile-webview-editor-web-fallbacks.test.tsx`, run against the plain field still in the tree: 4 failed, 6 passed — no toolbar, no URL modal, and the `TextInput` the page is meant to have lost. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put the editor's surface on the 16 px floor, and grow a census that can see it `#editor` computed to 14 px, measured in both engines. iOS zooms the page on focus of any editable under 16 px and does not zoom back, and `keyboard-occlusion.web.ts` answers 0 for the rest of the session at a scale other than 1 — the exact failure the floor exists for, on the page's only full-screen writing surface. The size now comes from the text-input seam, which is also where the two hosts part: the phone keeps the app's body size because a WebView has no page to zoom, the page gets the raise, and one binding moves both if the floor ever does. The `TextInput` census could not have caught it. `modulesDeclaringTextInput` matches JSX tags and `style` props, and this is a `contenteditable` in a markup string sized by a rule in a stylesheet. `mobile-web-app-editable-host-font-size.mjs` starts from the markup instead: it finds every editable host a closure declares, follows its id to the rule beside it, and reads the size the same way — a literal at or above the floor, or the seam's own export imported from the seam's module. An editable with no id, or one no sibling sheet styles, is reported unresolved rather than passed. Red first. The rule's own file reported `src/components/rich-markdown/document-style.ts:36` as the offender before the fix (4 failed, 3 passed on the first run, the other three being the brace scanner and the line-start anchor the fixtures found). The closure case in `mobile-web-app-session-terminal-closure.test.mjs` now names the editor as the one editable in the session route's closure and its offender list is empty: 1 passed, 4 skipped under `-t editables`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the caret survives the host's URL dialog, so Link and Image insert Measured in the render check, on both engines: the Link and Image commands opened the modal, took the URL, and inserted nothing. The dialog is what takes the caret — the modal focuses its own field — and `execCommand` on a document that does not hold the selection does nothing at all. So the page had swapped one silent failure for another: `window.prompt` returning null on the phone, and a command with no selection on the page. Two halves. The document remembers its caret before it waits and puts it back after (`restoreRememberedSelection`, unconditional where `restoreSelectionOrEnd` needs a flag, because the wait itself is the blur); if the host replaced the content while the dialog was open, the remembered range is gone from the document and the caret goes to the end instead. And the component answers the promise from the drawer's `onAfterClose` rather than from the submit, because WebKit would not take the focus back while the field still held it — with the answer released on submit, chromium inserted and WebKit did not. `TextInputModal` forwards `onAfterClose` for that, which is the one thing it did not already pass through to `BottomDrawer`. Red first, `editor-selection.test.ts` against the previous `editor-commands.ts`: 2 failed, 7 passed — the caret was left in the dialog's field, and a replaced document did not fall back to the end. The render check's Link/Image case went from failing on both engines to inserting on both, with the inserted image's `naturalWidth` above zero under the shipped policy. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's rich Markdown editor, in both engines under the shipped header `config/scripts/mobile-web-app-rich-markdown-render.test.mjs`: the real component, mounted by the real React, driven through its toolbar in chromium and webkit under the policy read out of the shell's own Kotlin source. Sixteen cases, eight per engine. What it measures rather than asserts: all fifteen commands change the document, each with the precondition that what it produces was not there first; the surface computes to the 16 px floor and the document's own `--editor-surface` variable is set on the host and nowhere on the root element; `ready` and `change` cross the seam while `window.ReactNativeWebView` — defined by the rig so its absence is a reading — is never touched; Link and Image are answered by the modal, and the inserted image paints with a non-zero `naturalWidth`; one change per checkbox tap and one per inline code; a link tap reaches the host instead of navigating; a remount leaves the listener snapshot and the scheduler exactly where one whole cycle left them (rulings 20 and 21); and two editors on one page hold their own content and report their own edits. Four harness facts the first runs found, each now in a comment: the entry needs four of `MOBILE_WEB_APP_SHIMS` (`isFabric` threw `global is not defined` and every case failed at `data-ready`); `.web.jsx` in `resolveExtensions` or `react-native-svg` resolves its Fabric components; a `SafeAreaProvider`, which the route's navigator supplies and a bare mount does not; and the document's markup, not its text, as the oracle for a content reset — `### body text here` and `body text here` read the same, so a text wait passed on the document it was replacing. One finding, reported not fixed: WebKit's `insertUnorderedList` nests the `<ul>` inside the `<p>` it was given and the serializer walks back out with the same text, so a bullet list does not survive a round trip there. The phone's WebView is the same engine, so this is not something the page introduces; the case names the command's own element and the reset is numbered per command to work around it. Run 5 of 5: 16 passed, 0 failed, 0 errors, exit 0, 6.58s. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session route's closure for the editor on the page Both sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `9267423f22`, all five postinstall generators run first, the before side a scratch worktree detached at that sha: modules 4333 -> 4360 (+27) local modules 991 -> 1018 (+27) All 27 are local and none is vendored, which is the point: the editor is the app's own code, not a library. The document's 24 modules under `src/components/rich-markdown/` were reachable from nothing on the page while it rendered a plain field, and the other three are the mount, the shared toolbar, and the controller with its keyboard-inset module. Nothing leaves, because the web sibling replaces its own native file and that file was never in this closure. Named by diffing the two `local` lists, not inferred from the total. `document-style-scoping.ts` is on both sides: the terminal's mount already brings it, so the editor's second export costs no module. The generation, measured the same way on both sides: 8,028,418 -> 8,056,166 bytes (+27,748) across 109 assets against the 9 MiB ceiling, 85.1% -> 85.4%. The script count does not move (67 against the 76 the chunk fence allows for 15 routes) and neither does the entry's static closure (1,612,052 bytes against 3 MiB) — this is code the route already reached for, not a new chunk boundary. The grant census needs nothing: `openExternalLink` is the editor's only seam with a grant behind it, and the session route already declares `externalLink` for six other openers. `mobile-web-app-page-grant-call-sites.test.mjs` passes unchanged. Closure, webview-consumer and grant censuses together: 28 passed, 0 failed, exit 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an oxlint directive the changed-code gate reads as unused `check-changed-code-quality.mjs` failed with one finding: the mount effect's `react-hooks/exhaustive-deps` disable reports no problem under that config, so the directive itself is the finding. The reason it carried is worth keeping and now reads as a plain comment — the effect mounts once, with `promptForUrl` taken from the closure, because re-running it would throw away a live document and the caret in it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): the editable-host census counts every editable tag, not every id CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:50`, and right on the code: the pattern started from `id="…"`, so it matched only hosts that carry one. The no-id guard fired for a file with *no* named host at all, which means a file holding a named host beside an anonymous one reported the named one as clean and said nothing about the other. An editable is its tag; the id is read out of the tag afterwards. Also `:145`, also right: the sibling search was `startsWith(directory + '/')`, which reaches the subtree, and the walk stops at the first file whose sheet opens the host's selector. The closure's order is the bundler's rather than alphabetical, so a sheet one directory down could answer for the sibling the host actually gets. Now the immediate directory only. Red first, both cases in the census's own file. The mixed fixture reported one host where two were planted (1 failed, 7 passed); the nested fixture, with the nested sheet first in the closure and a compliant 18 px rule in it, hid a 14 px sibling and reported no offender (1 failed, 8 passed). 9 passed after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): an editable with no declared size is unresolved, not a pass CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:110`, and right for the CSS case: `readFontSize` answered `onSeam: true` for a rule that declares no `font-size`, so the offender check accepted the host without being able to say what size it gets. The inherited value comes from a rule this walk does not read — the host element's own, or the page's root — and it can be 14 px. So "no declaration" becomes "cannot say" and lands in `unresolvedEditableHostStyles`, which the session closure census holds at empty. Not an offender: an offender is a size this walk read and found under the floor. The `TextInput` half of the seam still lets an absent `fontSize` through as inheritance. That is main's policy and it is about a prop rather than a cascade, so it is not touched here; the divergence is stated in the reader's own comment. Red first: the inheritance fixture reported no unresolved host where the size is unknowable (1 failed, 8 passed), 9 passed after. The real tree is unaffected — the editor declares its size on the seam — and the closure census still reads an empty unresolved list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): the editable-host census reads the font-size the cascade uses CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:119`, and right: the walk read the first `font-size` in a rule, and CSS takes the last of equal importance. `font-size: 16px; font-size: 14px;` was therefore reported compliant for a surface the browser renders at 14 px. `!important` outranks every declaration that is not, whatever the order. The flag is also stripped from the value, which the finding did not name but the fixture caught: without that, a compliant size carrying `!important` was reported as an offender, because it matched neither the literal nor the substitution shape. The declarations are split on the separator rather than matched with a value pattern. A pattern excluding `}` cut `${TEXT_INPUT_FONT_SIZE}px` at the brace of its own interpolation and reported the real editor as an offender — caught on the first run of the fix, and the reason the split is the shape here. Red first: 2 failed, 9 passed — the repeated-declaration fixture reported no offender, and the important-declaration pair reported the wrong one of the two. 11 passed after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): the render check reads the floor from the seam instead of retyping it pullfrog, `mobile-web-app-rich-markdown-render.test.mjs:37`, and right: the comment said the floor was read from the seam and the constant was the literal `16`, which is the shape the seam exists to prevent. It now comes from `textInputFontSizeFloor(mobileDir)`, the same reader the closure census uses, which throws rather than defaulting when the seam is gone. The assertion becomes "at or above the floor" rather than equal to it. The seam is `Math.max(bodySize, floor)`, so a theme raising the body size past the floor raises what the page computes; equality against the floor would have been the same stale literal one module further away. Two controls, both run. Raising `TEXT_INPUT_FONT_SIZE_FLOOR` to 18 in the seam keeps the case green on both engines, because the stylesheet reads the same module and the page computed 18 — the two moving together is the point. Replacing the stylesheet's `${TEXT_INPUT_FONT_SIZE}px` with a literal `14px` reds it on both engines, `expected 14 to be greater than or equal to 16`, which is what says the assertion carries weight. Both files were restored. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the editable-host census reads every rule that sizes the host (round 3) `ruleFor` returned the first exact `#id` rule and the walk stopped there, so a later exact rule of equal specificity, or a higher-specificity subject rule that still targets the host, could lower the rendered size unseen. Every exact rule in the sheet is now collected in source order and read as one cascade, and any other rule whose subject compound targets the host and declares `font-size` makes the host unresolved rather than compliant. No specificity arithmetic, and the sibling walk is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
197550c952 |
test(mobile): repin the session page closure at 4,333 after #18790 reached it (#22119)
#18790 added the freebuff agent icon to mobile-agent-icon-assets.ts, which the session page reaches. It merged between #22114's closure measurement (4,332) and its merge, so main pins one module short. Measured on main's tip |
||
|
|
0b1567a7b1 |
fix(mobile): catch the diff-comments loader rejection at the effect (OTA phase C follow-up) (#22111)
* fix(mobile): catch the diff-comments loader rejection at the effect `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` with no catch, so a *rejected* `worktree.show` raised an unhandled rejection on every session mount: a document-level error, not a page fault, and a red herring in crash reports and device proofs. The catch goes at the effect rather than inside the loader, whose promise the recording adapter awaits. `config/scripts/mobile-web-app-session-render.test.mjs` pinned the page's error list to exactly that one rejection; it is now the empty list, which is what makes the browser proof notice the fix. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record goldens without the diff-comments rejection The corpus certified the unhandled rejection the commit before removes, so the golden had to move with it. Scoped re-record: `baseline` bumped by editing that one line, then `--record`. Every diff line classified: - `baseline`, 788 lines (787 goldens + `pilot-scenarios.json`), and nothing else in 786 of them. - `matrix-session.diff-notes-worktree.show-1.json`, the only golden with a substantive change: three `unhandled-rejection` effects leave the pool (`da8252771fbd` incompatible_reply, `d638e32b9559` transport failure, `cf4fa55e3a8d` empty) and the four checkpoints that carried them now read `"effects": []`. No renumbering; no other effect key moved. - `HEAD_EFFECT_SHA256` to the measured `cc25f370…ebe522`. The 24-effect count is unchanged. `recorderSha256`, `lockfileSha256` and `adapterSha256` all hold. `baseline` is a branch commit, so a repin to main follows the squash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
226f4a0775 |
fix(mobile): two more table parsers hold a pipe in a cell (OTA phase C follow-up) (#22114)
* test(mobile): pin escaped pipes in mobile markdown table cells The mobile preview parser splits a table row on every pipe, so a cell that escaped one becomes two cells and keeps the backslash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read table rows through the shared row splitter The editor's markdown-table-rows already splits on unescaped pipes only and unescapes the cell; it has no imports of its own, so owning the rule once costs the preview parser nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin escaped-pipe rows in PR comment tables Its splitter strips the trailing pipe before walking escapes and reads `\\|` as an escaped pipe, so a row ending in `\|` loses the pipe and a cell holding a backslash swallows the separator after it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): split PR comment table rows on unescaped pipes only Its own delimiter grammar stays local: a single dash still opens a table here, which the editor's three-dash separator would reject. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): repin the session route closure at 4,332 modules markdown-table-rows.ts joins through the PR comment renderer. Measured on this head: 4,332 modules / 990 local, and it is the only file under rich-markdown/ in the closure, so nothing came with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
0677271709 |
fix(orchestration): reap leaked worker terminals via process-incarnation fallback — stops an unbounded PTY/process leak on Remote Server (OOM / cgroup PID exhaustion) (#18790)
* fix(orchestration): remint live handle from process incarnation on worker release When a durable terminal handle goes stale (rendererGraphEpoch fence), inspectWorkerTerminal re-mints a live handle via resolveTerminalHandleByProcessIncarnation + matchesProcessIncarnation so release/stop/read act on the still-running PTY instead of reporting missing and leaking the agent process tree. - keep main shared host-scope re-exports; add matchesProcessIncarnation - wire observation.terminalHandle through control/stop/release - rebuild release-completion on main structured paths - on missing/unattached + provably exited: settleDead fence first, then same-incarnation settleWorker fall back (archive may block settleDead mid-request); settle before recovery defer * fix(orchestration): derive SSH host scope from the reminted handle; reuse fresh-request recovery guidance for structured workers Addresses two open CodeRabbit review comments on PR #18790. inspectWorkerTerminal read the dispatch authority with the stale durable terminalHandle, so after a remint the lookup resolved nowhere and currentHostScope was always undefined — an SSH worker with no liveness verdict and no persisted host_scope got classified from terminal.connected instead of unverifiable. It now reads the same effectiveHandle every other observation in the function uses. stopStructuredWorkerForRelease told the caller to repeat the release with the same --retry-request, which only replays the stale release_unknown receipt and made a structured-worker close failure permanently unretryable. It now sources releaseUnknownRecovery from worker-release-completion so the fresh-request-ID guidance lives in one place. Pre-commit lint-staged (oxlint + oxfmt) run manually: clean. * test(orchestration): exercise incarnation recovery through runtime paths * test(orchestration): pin the incarnation read scenario to the reminted terminal The read scenario only asserted that the call resolved, so it documented nothing about which handle the read reached. Assert that the handle readTerminal received resolves to the registered pane and incarnation, so the scenario proves the read went through the reminted terminal instead of passing on the incarnation fence's throw. * refactor(orchestration): drop redundant incarnation prefix check; require liveTerminalHandle * feat: add freebuff as a first-class TUI agent (#42) <!-- orca-pr-loc --> <!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. --> | | Files | Added | Deleted | Net | | :--- | ---: | ---: | ---: | ---: | | Test | 0 | 0 | 0 | 0 | | Prod | 28 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$37 | 0 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$37 | <!-- /orca-pr-loc --> ## ELI5 Add Freebuff (`freebuff`) as a recognized first-class TUI coding agent in Orca alongside Codebuff and other supported agents. ## What Changed - Registered `freebuff` across shared TUI agent definitions, configuration catalogs, display names, and telemetry schemas. - Added agent icons, favicons, status mappings, and mobile asset references for Freebuff. - Added localization strings across supported language packs (`en`, `es`, `fr`, `ja`, `ko`, `zh`) and updated locale translation policy. - Documented Freebuff CLI in README agent table (`npm i -g freebuff`). ## Why Freebuff is a CLI coding agent twin of Codebuff (`npm i -g freebuff`). Adding it to the catalog enables users to launch worktrees, run automated sessions, and pick Freebuff directly within Orca. ## Linked Issue N/A ## Visual Proof `N/A` - Catalog registration and metadata definition for CLI agent launch; UI rendering uses existing TUI agent picker and status components. ## Testing - Verified TypeScript contracts, schemas, and catalog configurations. - Tested CLI detection / agent picker integration locally on Linux (`worktree create --agent freebuff`). ## AI Disclosure Assisted by AI coding tooling. ## Checklist - [x] This PR is small and focused - [x] I explained what changed and why (including ELI5) - [x] Before/after screenshots or videos attached for UI changes, or `N/A` with reason - [x] Self-reviewed for correctness, security, and performance - [x] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A) --------- Co-authored-by: Lesley Murfin <lesley@revivebusiness.ca> * test(orchestration): erase method overloads in worker reap fixtures * test: document worker fixture type boundaries * test: simplify worker fixture typing --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: svc-orca[bot] <313947298+svc-orca[bot]@users.noreply.github.com> Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
88f2f01061 |
fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY (#19430)
* fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY Root cause: daemon-launched-child.ts forks the detached terminal daemon with detached: true, which escapes the POSIX process group (setsid) but never the systemd cgroup. Every PTY the daemon owns is itself an undetached direct child of the daemon (native-pty-spawn.ts). Under a combined systemd unit (Type=simple, KillMode=mixed, per docs/reference/headless-linux-server.md), a systemctl restart/stop SIGKILLs every process still in the cgroup at the stop timeout -- the daemon and every live terminal -- even though the codebase already has a fully-built adoption/reattachment path for a surviving daemon (orcad-entry.ts's refreshRestoredOrchestrationAuthority + reconcileLegacyWorkerTerminals, gated on daemonOwnsFreshPersistentPtys()). That path never fires today because the daemon never survives long enough. Fix: when systemd is actually supervising the process and the OS user has a reachable systemd --user manager (isDurableDaemonScopeSupported(), Linux only), launch the daemon via systemd-run --user --scope so it lands in a cgroup that is a sibling of the service unit's cgroup, not a descendant of it. A systemctl restart of the combined unit then never reaches it. Any failure of the scoped launch (no reachable bus, D-Bus policy rejection, etc.) falls back transparently to the existing plain fork() launch, so every platform/environment without this capability is unaffected. The daemon self-detects its own resulting cgroup scope via /proc/self/cgroup (detectOwnCgroupScopeUnit()) rather than trusting the launcher's intent, and publishes it as cgroupUnit in its pid record and orcad's health/readiness payload (health.terminalDaemon.cgroupUnit), so a running deployment can be observed to confirm the fix actually engaged. No new session registry is added: the existing daemon pid-record + adoption protocol (publishDaemonPidFile, daemon-pid-record-quarantine.ts's dead-record reclaim, refreshRestoredOrchestrationAuthority) already implements durable, crash-safe reattachment for a surviving daemon -- it was simply never exercised against a full unit restart before now. Proven via a systemd-in-Docker recovery test: a live PTY session's shell process, its daemon, and the daemon's cgroup scope were all confirmed unchanged across a real systemctl restart of a Type=simple/KillMode=mixed unit, while the main process pid changed (confirming the unit actually restarted) and the new process's health payload recognized the surviving daemon as adopted and live. A fresh write into the same PTY post-restart reached the same running shell. Ordinary terminal create/work/release and the #18789/#18790 worker-release reap-fix regression tests are unaffected. Fixes stablyai/orca#19408 * fix(daemon): probe the real per-UID XDG_RUNTIME_DIR before trusting the process's own env isDurableDaemonScopeSupported()/buildDurableDaemonScopeCommand() trusted the current process's own XDG_RUNTIME_DIR env var first, falling back to /run/user/<uid> only when that var was unset entirely. On mtl-02, orca-serve@factory.service's RuntimeDirectory= hardening directive makes systemd export XDG_RUNTIME_DIR=/run/orca_serve/factory into the unit's process -- a private scratch dir that shares the env var's name but has nothing to do with the user session bus. /proc/<pid>/environ on that host confirmed exactly that path plus DBUS_SESSION_BUS_ADDRESS=disabled:, while the real bus was reachable the whole time at /run/user/985 (confirmed via systemctl --user is-system-running with that dir exported by hand). The probe treated the hardened override as authoritative, found no bus socket there, and reported unsupported on every launch -- so the cgroup-escape fix from #19408/#19430 never actually engaged on real hardware, even though tonight's factory deployment picked it up. Fix: resolveUserRuntimeDir() now always tries the conventional /run/user/<uid> path first (computed independently via getuid(), never trusted from env), checking for a genuinely connectable bus socket via statSync(...).isSocket() rather than a bare existsSync. It falls back to the process's own XDG_RUNTIME_DIR only when that canonical path has no reachable bus -- covering hosts that legitimately have no /run/user/<uid> at all but do have a working bus wherever their own environment points. buildDurableDaemonScopeCommand() now explicitly sets XDG_RUNTIME_DIR to whichever path this resolution picked, rather than inheriting the spread env's (possibly hardened-wrong) value. Both isDurableDaemonScopeSupported() and buildDurableDaemonScopeCommand() gained an injectable canonicalRuntimeDir parameter (defaulting to the real computed path) so tests can exercise the hardened-override scenario deterministically with a real, connectable AF_UNIX socket fixture instead of the live host's actual runtime directory. Docker's stock jrei/systemd-ubuntu test container never had this hardening directive, so this gap was structurally invisible to the container-based verification in #19430 -- only caught against real mtl-02 hardware. * fix(daemon): report the daemon's own pid over the ready handshake, not systemd-run's The launcher used to infer the daemon's identity pid from the immediate spawned child (`child.pid`). On the durable-scope path that child is `systemd-run --user --scope`, not the daemon, so the launcher was asserting an identity it had no authority over. `DaemonReadyIdentity` now carries a required `pid` populated from `process.pid` inside the daemon itself, and `daemon-launched-child.ts` takes `launchedIdentity.pid` from that self-report. Both sides of the `holdDaemonAdoptionLease` pid comparison therefore originate inside the daemon process, which is the idiom this branch already uses for cgroup membership (`detectOwnCgroupScopeUnit` reads `/proc/self/cgroup` rather than trusting what the launcher intended). Note on the reported consequence: `systemd-run --scope` registers its *own* pid on the transient scope unit and then `execvpe()`s the target command -- same pid, no intermediate process -- so adoption did not in fact fail on systemd >= 206 (verified against systemd 255.4-1ubuntu8.17 and current main, `src/run/run.c` `start_transient_scope()`). The fix stands on its own merits: it removes a silent dependency on that exec-vs-fork implementation detail, which a `systemd-run` shim earlier in PATH or any future systemd change would have broken with no diagnostic. `terminateLaunchedDaemonChild` was audited and deliberately left on `child.pid`: for the same execve-preserves-pid reason that pid is either still systemd-run mid-scope-setup (killing it correctly aborts the launch) or already the daemon, so it targets the right process either way. Regression coverage: `daemon-launched-child-identity.test.ts` pins the identity source, and `daemon-ready-identity.test.ts` gains pid-validation cases. Ready-message fixtures across the `daemon-init-*` suites were updated for the now-mandatory field. Addresses: https://github.com/stablyai/orca/pull/19430#discussion_r3953722704 https://github.com/stablyai/orca/pull/19430#discussion_r3954346518 * test(daemon): assert cgroupUnit in the pid-file parse contract `parseDaemonPidFile` returns `cgroupUnit` on every branch as of the durable-scope commit on this branch, but five exhaustive `toEqual` assertions in daemon-health.test.ts still described the pre-scope shape, so they failed on the branch independently of any later change. Adds the field to those expectations. Deliberately not relaxed to `toMatchObject`: asserting the full parsed shape is what makes these tests catch a field silently dropped from the pid-file contract. * refactor(daemon): resolve the canonical user runtime dir at one point The per-UID path cannot change for a live process, so compute it once into a module const instead of threading the same default call through three signatures, and drop the try/catch around a getuid() that cannot throw once it exists. Trims the module prose to the non-obvious facts and corrects the pid-file record comment: an unscoped daemon writes null; only records no daemon wrote are absent. * test(daemon): clean up the cgroup-scope fixtures and assert a verdict The cgroup fixture tracked only the file it wrote, leaking one temp dir per case. Drains both fixture lists with splice so the pop-may-be-undefined guards go away, and replaces a not-throw/typeof-boolean pair with the verdict it was circling: no resolvable runtime dir means unsupported. * refactor(daemon): share the detached child options across both launch paths cwd, detached and stdio were repeated in the fork and systemd-run branches, which left the two comments explaining them hovering over the env block instead. Names them once so each branch carries only its own delta. * refactor(daemon): validate the ready pid like every other field typeof-first narrows the value, so the two 'as number' casts the isSafeInteger check needed disappear and the pid guard reads like the startedAtMs guard below it. * fix(daemon): don't retry the launch unscoped after losing the endpoint race A scoped attempt that lost the endpoint to another daemon was retried unscoped: a second doomed fork, a misleading 'cgroup-scope launch failed' warning, and the same DaemonEndpointUnavailableError the caller was already going to adopt on. Rethrows it instead, since no launch mode can win a race that is already lost. Also drops a private alias for DaemonChildSpawnOptions and the two 'as number' casts on child.pid in the startup-failure cleanup. * fix(daemon): unlink the pid record by the pid the daemon published The record holds the daemon's self-reported pid, so match on that rather than on the immediate child's, which is the systemd-run wrapper's until it execs. * fix(daemon): route the scope launch through the child-process chokepoint The two files this PR added imported `node:child_process` directly, which `child-process-import-boundary.test.ts` fails on deterministically: the offender count went 155 -> 157 against a pin of exactly 155. Raising the pin or listing the files is what that test explicitly forbids, and the allowlist's own note says a split "moved the import, it did not add one" -- so the fix is to get both new files off the module and put the count back at 155. - `daemon-cgroup-scope.ts`: the `systemd-run --version` probe now uses `runProcessSync` instead of `execFileSync`, so it gets the shared spawn decisions. Kept synchronous deliberately: `launchDaemonChild` attaches the readiness listener in the same tick it is called, and an await before the spawn moves the child past that tick. A non-zero exit is data rather than a throw here, so the verdict now checks `code === 0 && !timedOut`. - `daemon-launched-child-spawn.ts`: the scoped launch uses `spawnProcess`, and the long-standing unscoped launch keeps `fork` semantics through a new `forkProcess`. - `src/shared/child-process/fork-process.ts`: the fork arm of the chokepoint. `spawnProcess` cannot express a Node child with an IPC channel started from a module path under an overridden `execPath`, and the existing launch tests are written against `fork`'s contract, so a spawn rewrite would have changed module resolution, `execPath` and `execArgv` at once. It passes `windowsHide: true` -- the flag every other call site in that directory sets, reachable via an assertion because `ForkOptions` omits it -- which keeps `windows-console-visibility.test.ts` at its pin of 65 too. Both ratchets pass with both pins and both allowlists untouched. Docs: `orcad-operations.md` and `headless-linux-server.md` still described the limitation this PR removes as permanent. Both now describe the durable-scope survival path and its preconditions (systemd as PID 1, a reachable user bus / `loginctl enable-linger`, `systemd-run` on PATH), and scope the old text to the unscoped-fallback case, pointing at `health.terminalDaemon.cgroupUnit` as the way to tell the two apart on a running host. * fix(daemon): seal the cgroup capability probe from the host and correct KillMode=mixed docs The capability probe consulted the host's own /run/systemd/system marker and spawned the real systemd-run binary, so the hermetic unit tests could only pass on a systemd host (and fail closed otherwise, even with faked bus sockets). - Thread systemdBootPath and runVersionProbe as test seams through isDurableDaemonScopeSupported, defaulting to the real boot marker and systemd-run --version probe in production. - Narrow the injected probe to the ProcessResult slice it consumes. - Cover: no-systemd-boot, non-zero probe exit, and probe-timeout cases. - Correct KillMode=mixed semantics in the docs: the cgroup-wide SIGKILL fires the instant the main process exits, not after TimeoutStopSec; document the Docker-container caveat and add KillMode=mixed to the multi-service template. * fix(daemon): satisfy assertion checks in scoped launch * fix(daemon): satisfy anti-slop and console guards * test(serve): update shutdown docs assertions for daemon scope * fix(daemon): migrate adopted legacy scopes * docs: qualify restart safety by daemon scope * docs(daemon): qualify Upgrade restart prose with durable scope caveat Align the Upgrade section in docs/reference/headless-linux-server.md with the earlier preservation section and docs/reference/orcad-operations.md: a service restart terminates live processes only when running under the unscoped fallback, and stops should be treated as destructive unless health.terminalDaemon.cgroupUnit names an orca-daemon-*.scope. Update the shutdown workflow test assertion in config/scripts/headless-serve-shutdown-workflow.test.mjs to match. * fix(daemon): harden legacy scope migration --------- Co-authored-by: Lesley Murfin <260182349+LesleyMurfin@users.noreply.github.com> Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
e1c8df41e5 |
fix(mobile): declare externalLink on the two page routes that reach the protocol wall (OTA phase C follow-up) (#22113)
* test(mobile): hold every page route to the externalLink call site it reaches The grant call-site census carried an exact allowance for the two routes that reach the shared protocol wall's `openExternalLink` without declaring `externalLink`. Removing it makes the census enforce the declaration instead of recording the gap; it now names both routes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): declare externalLink on the worktree list and agent history Both routes render the shared layout's `HostProtocolGate`, whose `ProtocolBlockScreen` opens its Update Orca link through `openExternalLink`, and neither declared the grant: the tap posted a notify the shell refuses, with nothing on screen. The census measures one call site in each closure, `src/components/ProtocolBlockScreen.tsx`. Repinned by measurement, with the manifest change named: the route-list pin, and the handed-off hop census, which goes 23 rows to 19. The four rows that leave are these two routes into the explorer and its preview — all four now declare the same four grants, so a tapped file stays in the document instead of costing a native frame and a second bridge session. A new case asserts that coverage, so the four absences are load-bearing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
86b93e02a7 |
feat(mobile): the microphone owns the wake lock, and the stop reply carries the tail (OTA phase C, ruling 36) (#22072)
* feat(mobile): give the microphone its own screen lock (OTA phase C, ruling 36) An open microphone holds the screen; a closed one gives it back. The lock lives in the device-side capture on both hosts — the shell's `native.audio.start|stop` handler and the native seam — so the page never decides anything about the screen. One tag per capture, minted by the module that owns the mic. Both captures give it back on every close path: a stop, a page session ending with the capture open, a device that would not begin, and an engine that throws after the capture is open, which now ends the capture rather than leaving it live. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): carry the capture's tail on the stop reply (OTA phase C, ruling 36) `native.audio.stop` drains what the ring still holds into its own reply, so the page's `end()` is one verb: stop, hand the bytes on, done. The drain, await and read-once-more ordering goes with it, and so do `ending`, `reading` and `released` — three variables that existed only to order a last read against the stop and to stop a refused read re-entering `end`. The tail fields default rather than being required: the page updates over the air and the shell does not, so a page this new can meet a shell that answers `stopped` alone. That dictation loses its tail where a required field would have lost it the stop. The heap case from PR D's bot round cannot recur: `end` issues no read, and a stop reply carries no interruption, so the lane that re-entered is gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the dictation finish id, which ordered nothing `finishingIdRef` tracked the dictation a stop was finishing, and every state it could name was already named: `cancel`, a disable, an unmount and a newer start each bump the generation or clear the active id, so the finish guard answered the same either way. Its one distinguishing arm released pending audio bytes for a dictation whose budget `closeDictationAudio` had just reset, and could subtract those bytes from a newer dictation's reserve. `acceptingChunksRef` stays: it is what stops a late microphone event being sent after the capture handed over its tail and before the finish goes out. `pendingChunksRef` stays: `stop` awaits it so the finish cannot overtake the last chunk send. The finish guard is pinned by a case that cancels while the finish is in flight; neutered, it reds. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): delete the page's wake-lock seam (OTA phase C, ruling 36) The page never names the screen now. `native.wakelock.set` is gone with its schemas, its shell server, its grant rows and its harness entry; so are the page's keep-awake owner, the Android foreground re-acquire, and the `DictationKeepAwakeDevice` the capture contract carried. One module holds the screen — the device calls the microphone's capture makes — and both device-side captures share its one tag, because there is one microphone. Deleted: native-wakelock.ts (120), native-wakelock.test.ts (140), mobile-dictation-keep-awake.ts (248), mobile-dictation-keep-awake.test.ts (440), mobile-dictation-foreground-keep-awake.ts (78). With the tag pools gone, the desktop-start flow has one stale check instead of two, no startup budget to wait out and nothing to release. A source-scanning census pins it: no module under mobile/src or mobile/app but the one owner imports expo-keep-awake, and nothing anywhere names the retired verb. It reports the file and line, and checks the owner does import the package so the absence is the rule holding and not the match missing. KNOWN RED, reported and not recorded over: 25 golden cases in the speech.* families fail. The recorder adapter had to drop its keep-awake owner, which moves `adapterSha256` for every golden that mounts it, and the deleted owner's id minting shifts the deterministic random sequence, so the recorded `dictationId` values move too. No speech.dictation.* param, reply or operation changed. Awaiting the lead's call on a scoped re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the corpus after the wake-lock deletion Baseline bumped to |
||
|
|
93e4407180 |
fix(build): admit typecheck projects by memory, not core count (#22074)
The typecheck job ran all four tsc projects at once whenever the machine had more than one core. Two of them are expensive -- tsconfig.node.json peaks at 6.3 GB of tsc heap and tsconfig.tc.web.json at 5.6 GB, measured with --extendedDiagnostics -- so together they reach ~14.5 GB on a 16 GB runner. Past that the runner agent is killed mid-check, and the job reports "The runner has received a shutdown signal" with an orphaned tsc, not a type error. Attempt-1 typecheck failures were 0 across Sept 11-17 and then 5-26% per day from Sept 18, with no change to the scheduler in that window. What moved was the codebase: src/main grew 23% and src/renderer 8.5% between Sept 1 and Sept 21, which is what pushed the pair over the line. Projects now carry their measured peak heap and are admitted heaviest-first while the batch fits both a memory budget and the core count, so the two expensive projects never share a runner. On a 16 GB / 4-core runner that plans node+cli+mobile-web (10 GiB) then web (6 GiB), measured at 8.6 GB peak instead of 14.5 GB. A roomy machine still runs all four together, so local typecheck is unchanged. A project larger than the whole budget still runs alone rather than producing an empty batch. The runner body moves behind the standard direct-invocation guard so the admission planner can be imported and tested without spawning tsc. |
||
|
|
8cf0e81ced |
fix(perf): calibrate report budgets without masking latency stalls (#22075)
* fix(perf): calibrate report budgets without masking latency stalls * docs(perf): record historical evidence for report limits |
||
|
|
d40aac0a58 |
test(mobile): repin the session page closure at 4,333 after #21924 reached it (#22067)
C7.7 (#21977) measured the session route's page closure at 4,330 on a merge of |
||
|
|
3cfb070294 |
feat(mobile): register the session page route (OTA phase C, C7.7) (#21977)
* feat(mobile): switch the session route to the shell, still unregistered (OTA phase C, C7.7) The review switch's shape, for its reasons. The session screen becomes `MobileSessionRouteScreen` in `src/session` because `useMobileSessionController` is 32 hooks deep and opens the terminal, chat and tab subscriptions: at the switch's top level it would open every one of them behind the page as well as in front of it, since hooks cannot be conditional. As an element passed for `fallback` it is built and not mounted. Four query params carried rather than re-derived, each omitted when empty: `name` is a label the screen otherwise derives from the workspace, `created` is the create flow's one-shot flag, `warning` is the host's own text, and `paneKey` is a notification tap. `paneKey` is the one the screen writes back — `use-notification-pane-navigation.ts` rewrites it to empty once it has switched, through `setParams` on the handoff, which inside the page is the document's own router — so it has to arrive in the page for that to happen at all. Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it; until then the flag is the only thing that changes and it is off. Three censuses red without their rows, measured on this tree: - `shell-screen-route-census.test.ts` `walks the route tree and finds them` named `session/[worktreeId].tsx` as a ninth switch the list did not have. - `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no others` reds without `SESSION_ROUTE` in `SWITCHED_ROUTES`. - `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` named the new sibling. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): root the session parity family at the screen the route mounts (OTA phase C, C7.7) The extraction parity pin walks from a root function in `app/h/[hostId]/session/[worktreeId].tsx`, which is now the flag switch: the walk found no `SessionScreen`, and the runtime-string count went 534 -> 542 on the switch's own param names and path literals. Rooted at `MobileSessionRouteScreen` instead, which is the function that calls the controller. The switch's business is which of the two screens renders, not what the session screen does, and its literals have no place in a hash about the extraction. Every pinned hash is unchanged, which is what says the body moved and nothing else did: 275 hooks, 77 callbacks, 24 effects, 534 runtime strings, 124 host and 61 leaf JSX facts, 172 style references, all at the same SHA-256 they had before the move. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the page the session screen's stored preferences (OTA phase C, C7.7) Ruling 7: nothing silently no-ops. The allowlist was one exact key and one prefix, so every preference the session screen reads inside the page fell back to its default and kept working outside it — a state the user cannot tell from a preference that does not exist. The keys are derived from the route's own closure, not copied from design §6. Nine join the list: `orca:terminal-accessory-layout`, `orca:custom-accessory-keys`, `orca:defaultSessionView`, `orca:mobileStructuredSendOperations:v1`, the three terminal preferences ruling 7 names (`orca:terminalTextScale`, `orca:terminalAutocompleteEnabled`, `orca:terminalLinkOpenMode`), and two the design did not: `orca:hostDockWidth`, which `use-mobile-dock-resize.ts` drags on this screen, and `orca:hostSidebarWidth`, which `app/h/_layout.tsx` reads above every page route and which the manifest already names as the reason agent-history declares `storage` at all. Two are per workspace, not per host. Design §6 has `orca:nativeChatTabs:<worktreeId>`; the module builds `<prefix><enc(hostId)>:<enc(worktreeId)>`, and `orca:terminalLiveInputDisabled:` has the same shape. So the narrowing goes one level in from C2.9's: `pageStorageKeysForRoute` and `isPageStorageKeyForRoute` replace the host-scoped pair, and a session page opened on one workspace can no more rewrite the tabs of the one beside it than it can another host's pins. Both sides read the workspace off the route pathname, which is the one fact the shell and the page are each handed. Every new key's writer notes the mirror before it persists, as `savePinnedIds` does: `init` is built synchronously, so a write that only reached the store would be one `init` behind. A refusal is a rejection, not a dropped write. The real AsyncStorage rejects when its store refuses, and the caller that matters already catches: the durable send journal answers "Message not sent" rather than putting a mutation on the wire with an operation id no store holds, which after a crash would send the message twice. `PageStorageRefusedError` names the key and which of the three refusals it was. Measured on this tree, which is why the journal needed more than an allowlist entry: one journal entry with no attachment serializes to 342 characters and 48 unsettled sends put the value past `PAGE_STORAGE_MAX_VALUE_CHARS` (47 is under it), against a schema that admits 4,096. `init`'s own `BridgeInitStorageSchema` refines on that bound, so handing the journal over whole refuses the *frame* and the session screen never opens at all. `pageStorageEntriesForInit` drops such a value and names it; the page reads a default, which is a degradation rather than a page that does not start. Red first, measured here: - 21 cases across three files on the host-scoped helpers being gone. - `leaves out a value the page would refuse the whole frame over` reds with the filter bypassed. - The journal case reds without the rejection, with the operation claimed against a store that never took it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): register the session page route (OTA phase C, C7.7) One entry in `MOBILE_WEB_PAGE_ROUTES` with ten grants, every one read off a call site in this route's own closure rather than carried from design §1. Measured here: navigate 7 handoff sites externalLink 6 openers haptics 24 trigger sites native.clipboard.write 6 sites native.clipboard.read 3 sites native.media.* 2 sites, one seam (`useMediaPicker`) screencastBinary 1 site (`MobileBrowserPane.tsx`) storage 10 exact keys and 2 workspace-scoped, the previous commit's `pageRouteGrants` is derived from this list, so the row is a consequence of the entry and there is no second table to edit. The design's list was exactly right; the counts are what say so. The hop census goes 16 -> 23, measured. All seven new rows are `X -> /h/[hostId]/session/ [worktreeId]`, one from each other page route, and none goes the other way: the session's ten grants are a strict superset of every other route's, so every hop into it is handed to the shell and every one of its own targets stays in the document. That second half is asserted as grant coverage rather than as the absence of seven rows — absent is also what an unregistered route looks like, which is the shape C4 already had to correct once. Two censuses gained the route and one is new: - The haptics seam census, whose route-module map moves to `mobile-web-app-page-route-modules.mjs` so the new census below shares it rather than keeping a second copy that stops growing when the first one does. - `page-served-back-control-a11y.test.ts`, which named two controls with no `accessibilityRole`: `MobileSessionHeader.tsx:64 role=none label=Back to worktrees` and `QuickCommandsSheet.tsx:160 role=none label=Back`. Both get the role. Inside the shell there is no native chrome behind them, so a bare Pressable is absent from the accessibility tree. - `mobile-web-app-screencast-lane-grant.test.mjs` derives `screencastBinary` from the closures the way the haptics census derives its token. C6 could not write it: the pane is mounted by a route rather than registered as one, so there was no route to pin the grant against (C6 ruling 3). The derivation census gains C6's half measured against this route rather than against a module closure read on its own, which is the other half of C6 ruling 3. The composed row for the session route's own families waits on C7.8's table, and on C4.5's split before it. Numbers, both ends measured on this tree, never summed: - Session route closure 4,328 -> 4,329 modules, 978 -> 979 local. The +1 is `MobileSessionRouteScreen.tsx`; the route file is one input either way, now the `.web.tsx`. - Chunk count 65 before and 65 after, against the 72 the fence allows at 14 route keys. The fence is untouched: a `.web.tsx` sibling is not a new route key, and this route shared its split. - Bundle 8,020,519 -> 8,022,202 bytes, 108 assets either side. `mobile-web-app-route-chunk-closure.mjs` looked the route module up by its exact path, and `resolveExtensions` puts `.web.tsx` first: the first route with a sibling to be asked for reached "no output". It tries the sibling first now, which is what the build actually chunked. Without the manifest entry these red on this tree: `pins every hop the handoff must take away from the page`, `keeps every hop out of the session local`, `declares only routes the bundle has a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`, both haptics-seam cases, and `declares the screencast lane on exactly the routes whose closure asks for it`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render-check the session route, and quiet the two things it found (OTA phase C, C7.7) The render check mounts the registered route in a real browser on the built bundle, under the header the shells send. It asserts the session screen paints rather than the Unmatched route, that the Back control reaches the accessibility tree as a real `<button>` with its name, that the route's own chunk arrives on a client-side navigation, that nothing it paints leaves the origin or logs a policy violation, and that the three reads the screen makes carry the workspace the route named — the precondition the rest needs, since a screen that mounted and asked for nothing would paint the same chrome. It also asserts, strictly, that the page and console errors are `[]`, which is what found both fixes here. Measured on this tree before them: two console lines and one uncaught rejection on every mount of the route, none of them visible natively. - `use-mobile-session-markdown-actions.ts` registered `BackHandler.addEventListener` with no platform guard, and the effect re-registers whenever the dirty-draft list changes. React Native Web answers "BackHandler is not supported on web and should not be used." and hands back an inert subscription, so the guard was never armed on the page anyway. Gated on `Platform.OS`, as the right drawer, the bottom drawer and the file preview already are. There is no hardware back in a WebView; the shell owns the phone's, and the page's Back control is where the prompt lives. - `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch. The loader returns on a *refused* `worktree.show` and nothing caught a *rejected* one, so a host that will not answer produced `Uncaught (in promise)` on every session mount. Caught at the effect rather than inside the loader, whose promise the golden recorder awaits; notes that did not arrive leave the ones on screen as they were, which is the module's own policy for a refusal. **The terminal is not painted here and the file says so at both ends.** A terminal on screen needs the host protocol handshake, a tab snapshot, a terminal inventory and a `terminal.subscribe` stream — five hand-written fixtures against five Zod schemas inside a transport double, which is what the harness's docstring refuses to become. Scripting `status.get` alone was measured here: the protocol gate reads it and the page paints "Update Orca on your computer" instead of the screen. What the terminal does under the shipped header is `mobile-web-app-terminal-render.test.mjs`, on the same component and the same build options. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh the session parity pins for the two seam edits (OTA phase C, C7.7) The previous commit's two fixes are inside the parity family, so three pins moved. Both edits are one token each and neither changes what a phone renders: - `'web'`, the `Platform.OS` guard the Markdown actions' `BackHandler` registration gained. - `"button"`, the accessibility role the session header's Back control gained. Runtime strings 534 -> 536, with the effect hash and the host-JSX hash moving for the same two. Everything else is unchanged: 275 hooks, 77 callbacks, 24 effects, 61 leaf JSX facts, 172 style references, all at the SHA-256 they had before. A separate commit because a reported head does not move by amend, and because the moved hashes are worth reading on their own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the diff-notes rejection instead of catching it (OTA phase C, C7.7) The `.catch` the previous commit added to `loadDiffComments` moved a golden, which is a finding rather than something to record over: `matrix-session.diff-notes-worktree.show-1` certifies the unhandled rejection as an effect of its loaded checkpoint, so the corpus says the app raises it today and a fix is a re-record and a review event. Reverted to `void loadDiffComments()`, with the defect written where a reader of that effect will find it. `family-recordings.test.ts > session.diff-notes: reply partitions at worktree.show#1` is green again; it was the one failure in an otherwise clean 8,699-test run. The render check keeps the observation rather than losing it. Its error assertion is now the exact list `['RenderCheckShellDouble: the render check answers no RPC']` instead of `[]`, so a second error reds it and so does this one going away — which makes the file the place the fix is noticed when someone lands it with the re-record. The defect, for that PR: the loader returns on a *refused* `worktree.show` and nothing catches a *rejected* one, so a host that will not answer raises an unhandled rejection on every session mount. It is not a page fault — the shell's `fault` notify comes from the React boundary and nothing reaches it — so the generation is not dropped and the screen works; the cost is a document-level error on every mount. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session effect hash after the diff-notes revert (OTA phase C, C7.7) The effect pin was refreshed while `loadDiffComments` carried a `.catch`; reverting that (the fix moves a golden, so it is a finding rather than a line) moves the same hash back off it. Repinned on the uncaught `void` call, which is what the tree holds and what the corpus certifies. Count unchanged at 24 effects; nothing else in the family moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): reject a page storage write for size only, and log the rest (OTA phase C, C7.7 round 1) Ruling 33.4. `PageStorageRefusedError` was raised for all three refusals, and two of them have no catcher: a page-closure writer of an unlisted key awaits `setItem` with nothing around it — `notification-delivery-preferences.ts:39` plainly, `preferences.ts` in several places — so a key the page was never allowed to keep became an unhandled rejection in the document. That is a worse failure than the silent drop it replaced, and it is the one the page can least afford, because an uncaught rejection there is a document-level error on a screen that is otherwise working. Scope is now one refusal. `too-large` rejects, because the caller that needs it is written for it: the durable send journal's composer catches it and answers "Message not sent" rather than sending a mutation whose operation id was never written down (ruling 7). `not-allowed` and `not-delivered` resolve and are logged as `[page-bridge] storage-write-dropped`, which is the old behaviour plus the line a device log needs — a preference that did not stick looks identical to one nobody set. A batch applies every pair it can, logs every drop, and rejects only if one of them was oversize. Red first, measured here: seven cases in `page-async-storage.test.ts` red on the rejection, among them a `notificationDeliveryPreferences` write resolving, another host's pins, another workspace's chat tabs, and a write the shell would not take. The oversize case is unchanged and still asserts `PageStorageRefusedError` with the key and the character bound in its message, so the narrowing is visible as the difference between the two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): make every count in the session route say the same number (OTA phase C, C7.7 round 1) Ruling 33.5. Three numbers were stated more than once and two of them had drifted when the merge took the grant list from ten to fourteen. - Grants. `mobile-web-page-routes.mjs:100` and `mobile-web-page-route-hop-coverage.test.mjs:57` both still said ten. Fourteen in both, and the manifest comment now names the audio verbs beside the media ones as things only this route asks for. - Keys. The manifest said `storage` covers "the ten exact keys and two workspace-scoped ones", which counts `orca:last-visited-worktree` — a key this route did not add. Nine exact plus the two workspace-scoped, which is what C7.7 put in `page-storage-keys.ts`. - The journal entry. 342 and 343 are both real and answer different questions, which is exactly why one number had to win: an entry serializes to 342 characters on its own and costs 343 in the array, the difference being the comma that joins it. 343 is the one that drives the threshold, so it is the one stated, with the 342 kept beside it as its derivation. Re-measured here rather than carried: 47 entries are 16,140 characters and 48 are 16,483, against the 16,384 cap. Comments only; no behaviour and no assertion moved. The threshold case in `mobile-structured-send-page-storage-refusal.test.ts` already asserted the boundary both ways and still passes unchanged, which is what says the arithmetic above is the code's and not the prose's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): red-first for a pane request over a re-sent init (OTA phase C, C7.7 round 1) Ruling 33.1's four cases plus the compatibility one, all red: `publishRoute` is not a member of the host, `onRouteUpdate` is not a member of the page's client, and `ready` carries no `accepts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): deliver a pane request to the mounted page over a re-sent init (OTA phase C, C7.7 round 1) Ruling 33.1. The session switch keyed on the whole route, so a notification tap for another pane of the session on screen either remounted the shell (a bridge teardown and a page reload for a tab switch) or, for the pane already showing, moved nothing at all: the page cleared `paneKey` on its own router and the native param kept it, so `SET_PARAMS` wrote the value already there. `paneKey` leaves the key and travels as a route update. The page declares `accepts: ['route-update']` on `ready`; the shell re-sends `init` for a same-path param change only to a page that declared it, and treats a second `init` for the session the page already holds as a route update rather than a replacement -- in-flight requests, subscriptions, the storage snapshot (the same object, asserted) and the generation all stay. The screen reports delivery and the switch clears the native param, so no later `init` replays a spent tap. `use-notification-pane-navigation.web.ts` reads the request off a standing listener; the native file is unchanged. Wire-compatible both ways without a version bump: `accepts` is optional, an older page is never sent a second `init`, and an older shell never sends one. Both degrade to today's lost repeat tap. `BRIDGE_PROTOCOL_VERSION` and every released native RPC are untouched. Two files were at their line cap, so two modules came out at their own boundaries rather than a cap bump: `bridge-init-route.ts` (the route half of `init`, wanted by the switches, the host and the page) and `bridge-host-route.ts` (one host's held route and what it may publish). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the session switch and its hardware-back gate get their own tests (OTA phase C, C7.7 round 1) Ruling 33.2. `mobile-web-shell-session-route.test.tsx` mirrors the eight cases the files switch has -- route built, native fallback while the flag settles, repeated params, dot-segment refusal, segment encoding, flag off, remount on a route change, remount on a param change -- plus the two pane cases: a repeat tap for the same pane reaches the mounted page twice and a different pane reaches it once, both with one mount in the lifecycle. The `BackHandler` gate gets a unit test in the shape of its three siblings. Reaching it meant the hook declaring the fourteen fields it reads instead of taking all 268 of the session model, so a probe can render it without building a session; `MobileSessionDiffCommentsModel` satisfies that by construction and the one caller is unchanged. No pin in the session parity census moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a call-site census for the six grants that had none (OTA phase C, C7.7 round 1) Ruling 33.3. `navigate`, `storage`, `externalLink`, the two clipboard verbs and the media three were pinned only by the list they were copied from, so striking any of them out of a manifest entry reddened nothing. Each is now derived from the route's own closure by parsing the call sites -- a call, not a mention in a comment or a string, and not an import the module never calls -- and each row has a named control case driven over the session entry with that row's grants struck out. It found one thing. `app/h/_layout.tsx` wraps every `/h` route in `HostProtocolGate`, whose wall offers an Update Orca link through `openExternalLink`, and two routes reach that without declaring `externalLink`: on them the link posts a notify the shell refuses. Recorded exactly as `KNOWN_UNDECLARED` rather than exempted, because widening two other routes' grants is a capability decision and this is pre-existing on main. `notificationPaneTab` moves to its own module. A `.web.ts` sibling cannot import its native neighbour by the plain path: the bundler's `resolveExtensions` answers with the `.web.ts` file, so that import was the file itself and esbuild refused the page bundle with a cycle. The mobile suite does not bundle, so only the closure walk saw it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a struck-out grant red a case named after it (OTA phase C, C7.7 round 1) The first shape checked the whole manifest at once, so removing any one of the eight reddened all seven cases and named none of them: the per-row control read `session.grants` off the manifest the removal had just changed. Each row now has its own manifest case, and each control is built from what the session route's closure reaches rather than from what its entry declares, so it stays green whatever the manifest says. Closures are memoised, which is what pays for walking all eight once per row. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the pane request in a ref, not in state (OTA phase C, C7.7 round 1) The changed-code quality gate's React Doctor found it: `no-adjust-state-on-prop-change`. A tap can arrive before the terminals have loaded, so the request has to wait; holding it in state meant the effect that consumed it set state on a prop change, and the stale selection renders first. The request waits in a ref now and a counter wakes the effect, so the effect reads and clears rather than adjusting anything. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): re-measure the session closure and list the new web sibling (OTA phase C, C7.7 round 1) The full `config/scripts` suite found both. The closure reads 4,326 modules and 984 local, two more than the merge, and the two are named rather than counted: `notification-pane-tab.ts` and `bridge-init-route.ts`. The pane hook's web sibling replaces the native file rather than joining it, so it costs nothing -- but it is a `.web.ts`, so it needs its row in `web-overrides.json` saying why the native one cannot run on the page. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the page off a journal init could not carry (OTA phase C, C7.7 round 1) Ruling 33.6, from pullfrog on `beda1cc384`. Dropping an over-cap value from `init` did not revoke the page's write access to it: the key stays in `pageStorageKeysForRoute`, so the page read no journal, `parseJournal(null)` gave it an empty one, and its first send wrote a one-entry value over the device's -- every native entry lost and a fresh `operationId` for an operation the native journal already held, which is the duplicate send ruling 7 exists to prevent. `pageStorageEntriesForInit` now reports `oversize` beside `dropped`: only the value-cap drops, because an entry-cap drop is a key that fits and the page's own write of it is the size the shell would have carried anyway. The shell sends those names as `init.storageOversize`, and a page write to one of them rejects with `PageStorageRefusedError` under the size contract of 33.4, which the composer already shows as "Message not sent". The native journal is untouched until the user is back on native or it drains. `storageOversize` is optional in both directions: an older shell sends none and an older page ignores it, which is exactly today's behaviour. No version bump; omitted rather than sent empty, so no golden moves. Red first, with the two states replaced by ones the shell produces. The 47/48 case drives `pageStorageEntriesForInit` rather than publishing a journal value the shell strips before `publishPageStorage` ever sees it, and the case that used to assert a successful write now asserts the native entries survive: it was the clobber, recorded as success. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): deliver a route update only when a param moved, and only after init went out (OTA phase C, C7.7 round 2) Round 2, findings 1 and 2, both red first. `onRouteUpdate` fired on every re-sent `init` for the session the page holds, not only on one whose route moved. The shell answers every `ready` with the route it holds and the page re-asks on its own backoff and again after a refused `state` frame, so one tap reached the pane hook as `['', 'pane-1']`. Both ends now read one definition of moved, `bridgeRouteMoved`, which is the page's own `shellScreenRouteKey`: the host will not send an `init` for a route that did not move and the page will not publish one it was sent anyway. The `.web.ts` hook keeps its empty-pane guard and its comment now says why it is load-bearing rather than defensive -- the shell's own clear arrives as a move. `onRouteDelivered` ran on the `ready` path without checking that an `init` had gone out. A refused route answers the ask with nothing, so the caller would clear a one-shot param the page never received. `sendInit` reports whether a frame left and `onPageReady` carries it. Unreachable from the session switch, which parses the route before it mounts the shell; the prop's contract says it anyway, and the publish path already honoured it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): attach the init-storage doc to its type, keep the overrides escape (OTA phase C, C7.7 round 2) Round 2, findings 4 and 5, neither a behaviour change. The block describing `pageStorageEntriesForInit` had `PageStorageForInit` and its own one-line doc between it and the function, so it documented neither. The type moves above it and the block sits on the function it describes. `web-overrides.json` had an escaped em dash re-encoded as a literal one when this branch added its rows through a JSON round trip, on a line about the keyboard stub that has nothing to do with C7.7. Main's `—` is restored; `oxfmt --check` accepts the file either way, so this is main's spelling kept rather than a formatter's demand. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): catch the custom-key save the page store refuses (OTA phase C, C7.7 round 2) Round 2 addendum. `addKey` awaited `saveCustomKeys` with no catch and both of its callers are `void addKey(...)`, so the rejection had nowhere to go. `orca:custom-accessory-keys` is in the session route's page allowlist and a page write over `PAGE_STORAGE_MAX_VALUE_CHARS` rejects rather than drops (the size contract of 33.4, extended by 33.6 to a key `init` could not carry), so past ~16 KB of accessory keys this surfaced as an unhandled rejection in the page -- which the fault boundary reports and which drops the generation. Every other allowlisted writer in this closure already catches: the two write chains in `TerminalShortcutSettings`, the live-input save and the session-view preference. Caught at the boundary and logged, and the drawer neither announces the key nor closes: a row on the accessory bar that no store holds, gone at the next load, is the failure the allowlist exists to avoid. Red first -- the case saw the refusal escape with the page's own message -- and the control reds again when the catch rethrows. Belongs in `8b4c559e90` by the brief; it is its own commit because that one was already made and amending is forbidden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): reject a batch of oversize writes once, not once per pair (OTA phase C, C7.7 round 2) CodeRabbit and pullfrog, same site. `settleBatch` called `settle` per refusal and kept the first rejected promise, so a `multiSet` or `multiRemove` with two over-cap pairs built a second rejected promise nobody held -- an unhandled rejection in the page, the outcome ruling 33.4's rejection scope exists to avoid. Two oversize keys is all it takes, and `storageOversize` made a second way to reach it. A refusal is now an error or nothing, and only the caller's one rejection ever becomes a promise. Red first under an `unhandledRejection` listener with two over-cap pairs: one orphan before, none after, and the caller still hears about the first key. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): record a route key only once a frame carried it (OTA phase C, C7.7 round 2) CodeRabbit on `MobileWebShellScreen.tsx:271`. The effect recorded the route's key and then published, so a publish the hook refused for having no host was remembered as though it had gone out. `publishRoute` is now keyed on everything the host is built from rather than on the session alone, so the render that brings the host re-runs the effect, and the key is written only after a frame has left. Reported honestly: this does not repair a lost tap, and the case beside it says so. The host is built from the route the render holds, so a route that moved before it existed rides the first `init` either way and `publishRoute` then answers "did not move". What the change removes is a key recorded for a frame nobody sent -- the same contract finding 2 fixed on the `ready` path. The case pins the delivery count across the gap: nothing reported while there is no host, nothing reported once there is one and it has sent nothing, and exactly one report when the `init` answering the page's ask carries the route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse an oversize-dropped key at the shell, not only at the page (OTA phase C, C7.7 round 2) pullfrog's rollout gap on ruling 33.6. `storageOversize` is honoured by a page built with it, and the page is served from the desktop: a document from an older bundle ignores the field and writes the key whole, which for the send journal replaces every entry the device holds. The shell is the half that updates with the app, so the shell is where the refusal has to live. The host now refuses a `storage` notify for a key it could not hand the page, answering it as the drop it already answers an unlisted key with. The page's own rejection stays as the fast path -- it reaches the composer as "Message not sent" with no round trip -- and the schema comment says the field is advisory and the shell enforces it. Red first: a host holding the journal as oversize received a page write for it and posted it to native storage; now it posts nothing and the entries survive, while a key it did hand over is still writable. The three refusals became one predicate in `page-storage-keys.ts`, where the keys are, because inlining the third put `bridge-host.ts` over its line cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's pane hook gets its own test (OTA phase C, C7.7 round 2) pullfrog: `use-notification-pane-navigation.web.ts` had no cover. The native file's test mounts the native file, and `bridge-route-update.test.ts` stops at the client, so the half that turns a route update into a tab switch was untested. Seven cases: the seed from the route the page was opened on, a request held until the terminals load, a repeat tap on the pane already showing, a different pane, the clear the shell posts after each delivery, a pane that has since closed, and a page opened on no pane at all. Two controls, so the cases are not all satisfied by one behaviour. Dropping the seed reds the two that read the first `init`. Deduplicating by value instead of counting deliveries reds the repeat tap, which is the case the counter exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): roll the custom-keys mirror back when the store refuses (OTA phase C, C7.7 round 2) CodeRabbit. `saveCustomKeys` notes the write in the mirror before it persists, because a reader is answered from the map rather than from the store and the shell builds `init` synchronously from that map. On a refused write the note stood: the page's next `init` carried the value native had rejected, and every native reader of the key saw it too. The previous mirrored value is captured and put back on the failure path, and the error still goes to the caller so `addKey` keeps withholding the key. Red first: with the store refusing, the mirror held the rejected value where the pre-save value belonged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report a route delivered only after its frame was posted (OTA phase C, C7.7 round 2) CodeRabbit on the send path. `sendInit` answered "sent" the moment it handed the JSON to `post`, and a rejected post was reported a turn later as a diagnostic -- so the screen spent the one-shot `paneKey` on a frame the page never received, cleared the native param, and the tap was gone. `publishRoute` was fire-and-forget the same way. Delivery is a promise now, settled after `options.post` resolves and false on either throw or reject. Readiness stays separate: `onPageReady` fires on the ask, as the shell's wait needs, and carries the delivery promise beside it. The screen records the route key and calls `onRouteDelivered` only when that promise answers true, and a refusal leaves nothing recorded so the next render that can carry the route tries again. Red first: with the view refusing what it was handed, the frame was built and posted and the screen reported delivery anyway. Now it reports none while the page's ask is still reported, and a host-level case pins the same split. `bridge-host.ts` was at 299 of 300 lines, so the send half came out as `bridge-host-frames.ts` rather than growing it; the file now measures 280. The screen's own test harness never attached the view handle, so every post in it rejected unobserved -- it attaches one now, which is what let the case see the frame at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): fold two doc blocks back onto what they describe (OTA phase C, C7.7 round 2) pullfrog's two nits, no behaviour change. `page-async-storage.ts` kept the old `settle` block above `refusalError` when the function it described moved down with a one-liner of its own; the orphan goes. `bridge-host.ts` had two stacked blocks on `sendInit` after it grew a return value; they are one, and it now says the frame is still built synchronously and only the post is awaited -- which is the property the golden recorder depends on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the host own a pending route until its frame lands A route the page has not received is now the host's, not the screen's. `publish` keeps it pending until a post resolves true, marks it delivered only then, and reports that through a callback registered once per host. Movement is measured against what a frame actually reached the page with rather than against what the host holds, so a refused frame leaves the route owed instead of reading as one that did not move. Three things the old shape lost, each a case here: a frame the view refused was never retried, because only another render could try and a mounted page has none coming; a render while a post was in flight cancelled the report the switch spends to clear the param; and a repeat tap for the same pane was held, because the host had already moved its held route on the attempt that failed. The retries are the moments delivery becomes possible again — the next `ready`, and a view handle the host regains — and one frame goes out at a time. Also folds round 4's doc nits: the stale delivery comment the screen no longer has a ref for, and a leftover `an` in the `ready` branch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): roll the journal mirror back when persistence fails `writeEntries` noted the mirror before the store took it, which is what keeps an `init` built in the same turn current — but it kept the note when the store refused. The page then received a journal the device never wrote and resumed operations nothing was holding. Restored on the error path, the same shape as the custom-keys save, and on both halves: the removal that empties the journal had the same gap as the write that fills it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep publishing until the held route is the delivered one Two halves of the same gap, both found by the bots on `fba8cb3f3d`. A route that moved while a frame was out was held for the turn and then had nothing to wake it: the post settling only cleared the in-flight flag, and on a mounted page no `ready`, handle or tap need ever come along. A landing is now itself a moment to publish again, while what the host holds is not what the page has. Only on a landing — a refused post that re-attempted itself would spin, and that one still waits for whatever makes delivery possible again. And the report carries the route a frame reached the page with, which the session switch was ignoring: the older pane landing wiped the `paneKey` naming the newer one, so the page stayed where it was and the second tap was gone. The switch now spends the param only for the pane that was delivered. The delivery cases render through one helper rather than six copies of the same setup, which is what keeps the file under its cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop the second block describing a parameter onPageReady no longer takes The field is documented by the block above it; this one still described the `delivered` promise the handler was handed before the host took ownership of the pending route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the page erase the route param it was handed Ruling 34, step one: one page-to-shell frame that asks the shell to clear a one-shot route param, naming the value the page applied. Closed at both ends. The param is an enum of what the shell hands over, so a page cannot edit a route it was never given; the notify name is a member of the closed union, so it gets a row in the grant table by compilation rather than by memory, and rides no grant because it can only spend something this shell put there. The shell declares it in `init`, the mirror of `ready.accepts`: no shipped shell serves a page, so nothing needs negotiating today and the page's check exists from the first version that can post one. The comparison belongs to whoever holds the param, which is the session switch: a tap that moved on while the page was applying the one before it leaves a newer key, and a clear naming the older one is not for it. `bridge-envelope.ts` went over its cap, so the page-to-shell union moved to `bridge-notify-envelope.ts` and the fields both halves spell to `bridge-frame-fields.ts`, which the envelope re-exports. No cap was raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): re-send init on a route change and track nothing else Ruling 34, step two: the tracked handoff is gone. Deleted, not patched — the pending route, the delivered route, the in-flight flag, the landing callback, `retryPendingRoute`, the delivery promise `onPageReady` used to carry, and the `onRouteDelivered` that ran from the host through the hook and the screen to the switch. What is left is the rule in one line: `publish` sends one `init` when the route moved and the page said it takes one, and every `ready` is answered with the route the shell holds then. A frame the view refused is repaired by the next ask, not by a retry; the request it carried is spent by the page. The cases that tested the deleted mechanism go with it. The outcomes they protected are pinned where they now live: one frame per move and none for a render that moved nothing, a lost frame repaired by the next ask, no second `init` to a page that never said it takes one, and the repeat tap measured through the page's erase rather than through a delivery report. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page applies a pane and erases the request that carried it Ruling 34, step three. The page hook applies the pane an `init` names and asks the shell to erase the param it came on, naming what it applied. Two rules, and both are the page's because the shell has none. The erase is asked for on every `init` that carries a pane rather than only on the one that changed something: a clear that never reached the shell leaves the param in place, and the next frame carrying it is the repair. The switch happens once per value: a re-asked `ready` is answered with the route the shell still holds, and applying that again would drag the page off a tab the user has since moved to. A repeat tap for the same pane still arrives as a request, because the erase went through in between and the tap wrote the param back. The client refuses to post the frame to a shell that did not declare it takes one, which is every shell older than the field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the page bundle's readers pointed at the module that declares each name The envelope split left three source-text readers and one import pointed at a file that now re-exports what they read. `shell-screen-route.ts` read the route schema back through the envelope, which reaches that file again through the page-to-shell union: a cycle esbuild resolves to `undefined`, so every page route mounted onto a schema that was not there yet and the browser render suite failed on twelve files with a TypeError rather than on a build error. It reads the declaring module now. The render harness read `BRIDGE_PROTOCOL_VERSION` and `BRIDGE_FAULT_GRANT` out of the envelope by regex; both moved, and a regex over a re-export answers for whichever file the last split left them in. Both point at `bridge-frame-fields.ts`, and the throw names it. The session route's page closure is re-measured on this tree at 4,330 / 988 and the four new modules are named, not inferred: the two halves of the split envelope, and the route-update module and route-key reader the page-to-shell union now reaches through it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count posted inits with the page's own reader, not a cast The changed-code quality gate refuses a type assertion, and it is right to here: a frame the page's reader would refuse is not an `init` the page ever saw, so a case counting them must not count one either. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): put every mirrored write on one path that notes what the store took Ruling 35. Fourteen call sites in six files noted the shell's mirror before persisting, and on the page a persist can be refused: twelve left the map holding a value no store had taken, and the next `init` handed the page exactly that. Two undid it by hand. `persistMirrored` is the one path now, and it seats the map from what the store holds after the write rather than from what it was handed. That is what makes the note follow acceptance without a second opinion about it: the page's adapter resolves a `not-allowed` write and logs it, so a rejection is not the only refusal there is, and reading back is the only answer that covers both. The cost is one store read per mirrored write on the device, where the store refuses nothing. `writeMirroredStorage` keeps its note-then-persist order and loses every caller but one: the shell taking a value the page has already applied, into the device store, which has no allowlist and no frame cap to refuse against. It builds the next `init` synchronously in the same turn, so noting on the store's reply there would hand the page back the value it just changed. The last-visited key moved off it, because that module is in the page's own closure. Both rollbacks are gone with the notes that needed them, and `noteMirroredWrite` is private. A source-scanning census holds each writer to the path by name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): answer a page batch write at the first pair it cannot take Ruling 35's other half. `settleBatch` collected a refusal per pair, logged each, and rejected with the first that could reject while the rest of the batch went in anyway — one promise describing a call where some pairs landed and some did not, which is not something a caller can act on. A batch is one call with one answer now: every pair before the refusal is applied, the refusal is the answer, and nothing after it is attempted. No page-closure writer calls `multiSet` or `multiRemove` today, so this is the rule for whoever writes the first one rather than a change to anyone's behaviour; both directions are pinned, including the refused first pair that stops the rest. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the mirrored write path's census and journal writer Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the note-first callers the mirror module says are held to one Two gaps pullfrog found in the census. The last-visited key moved onto `persistMirrored` with no row naming it, so removing its write path reddened nothing; and `mirrored-storage-keys.ts` says the census holds `writeMirroredStorage` to one caller while nothing counted them. Counted now, over every module under `mobile/src` rather than over a list of files a new caller could sit outside of: a second one is either a writer that wants note-then-persist without the store that earns it, or a page-reachable module that would note a refusal as an accepted write. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): fold sendInit's doc onto the function it now describes It still described an awaited post that answered whether the page received the frame, which ruling 34 deleted: it fires the frame and answers nothing, a refused route sends nothing at all, and a post the view would not take is one diagnostic and no further attempt. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the page own a frame it received and could not handle Ruling 34's addendum. On iOS the host's post is `callAsyncJavaScript`, which rejects when the page's synchronous `onmessage` throws — with the document still mounted. The shell reads that as a frame that never arrived, and it tracks nothing about posts, so nothing would ever send it again. It is not a lost frame either: the page had it, one of its own listeners failed, and a retry would fail the same way. `receive` catches it and reports `inbound-listener-threw`, so the delivery is the channel's and the handling is the page's. Nothing is swallowed and nothing is retried. Two cases pinned the throw escaping and now pin it being reported: the ack that a listener bug must not wedge, and the bootstrap stamp a tree that throws still leaves behind. With that path closed, a post is refused only when no document holds the view, and the comments on both halves of the route seam say so instead of naming a backoff that is stopped by then. The repair is pinned rather than described: a tap that arrives while the view is gone is carried to the next document's `ready`, because the held route advances on `hold` as well as on `send`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the page's held session out of the client, which was at its cap The listener catch put `bridge-rpc-client.ts` at 305 counted lines against a cap of 300, so this splits rather than bumps. The session is the one piece of the client with a lifecycle rather than a value: a second `init` for the same session updates it in place, a different one replaces it and takes the requests and streams of the session before it, and each case has its own listeners to fire in its own order. The client keeps the frames and the ports; `bridge-client-shell-session.ts` keeps what they are for, and the client's three members delegate to it. The client measures 277 counted lines after the move. The session route's page closure is unchanged at 4,330 / 988: the page reaches its client from the entry, not from the route module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5e25c7a0b1 |
test(frame-budget): raster the noise before the sweep measures a frame (#22048)
The sweep's capture waited two animation frames after painting the noise and then measured the next screencast frame. That is a main-thread commit, not a raster: the screencast hands over whatever the compositor has drawn so far, so after a resize it emits frames at the full size carrying only the tiles rastered yet. Measured under CPU starvation at 1400x1600, 87 of 444 post-commit frames read under the noise floor, one of them 447491 bytes where the full frame is 1221117. A frame that short posts inside the 640 KiB cap, which is what failed "the shell drops what will not fit" with `expected 596462 to be null` on a lead gate. `Page.captureScreenshot` returns only once a compositor frame of the current content exists, so it is the raster the measurement needs. It is ordered on the same CDP session, so every frame counted after it is one the compositor had finished. Over the same rounds with it, none read under the floor. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
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
|
||
|
|
d199e71a8e |
test(config): wait for the benchmark fixture before timing out its group (#21982)
The owner-loss group case spawned its fixture with a 100ms kill timeout and then read the child.pid handshake the fixture writes, so under full-suite load the launcher was SIGKILLed before it booted and the read failed with ENOENT. Start the launcher detached, wait for the handshake, and only then send the trial timeout's own SIGKILL, which leaves the same orphaned group the cleanup path validates. 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 |
||
|
|
76439ef00d |
test(mobile): put the frame-budget noise floor between the two encoders (#21972)
The sweep asserted that its minimum noise cost stays above 0.5 bytes per pixel, a floor chosen against the pinned Chromium's 0.543986. The runner's Chrome 152 encodes the same seeded noise at 0.480898, so the floor sat inside the spread between two encoders and failed a green run on a measurement that was noise. The averaged-noise arm the floor exists to catch reads under 0.3 on both. The floor is now one named constant at 0.4, used by the sweep and by the arm that proves the floor catches an averaged canvas, with both readings recorded beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
963da57cc3 |
test(config): preview rig readiness polls the main world, never the utility world (Chrome 152 hang) (#21963)
* test(mobile): wait for the preview frame in its main world, and probe the world that hung
Three cases spent their whole 180s on CI's Chrome inside `waitForSelector('#marker')` while
the diagnosis reported, from the same frame, `readyState: complete` and `marker: true`.
Those two readings ask in different worlds. `frame.evaluate` needs only the frame's main
execution context; a selector wait needs Playwright's injected script in Chromium's utility
world, an isolated world created per document by a command whose failure the driver swallows
and whose creation event it drops for a frame the driver considers stale. With `timeout: 0`
a world that never arrives is a wait that never ends.
So readiness is main-world polling now: the frame is resolved again from `page.frames()` on
every attempt and the predicate runs through `frame.evaluate`, still bounded by the case's
own `ctx.signal` and still ending in the diagnosis. The evaluate is abandoned after a second
so a frame that never answers cannot outlive its own replacement.
The diagnosis gains the reading that would have settled this in one run: a bounded
`utilityWorld` probe per frame, printed beside the main-world reading, so the split is
measured rather than inferred again. The competing explanation is ruled out in code --
Playwright closes a detached frame's scope with an error that every wait races, so a stale
Frame rejects rather than hangs.
Not proven red-first. Chrome 152 is the only engine that has shown this and it is not
available here; chromium 147 and WebKit 26.4 both build the utility world and both report
`utilityWorld "resolved"` for the sealed `srcdoc` frame. What is proven locally: 18 of 18 on
both engines, and a deliberately marker-less artifact still ends in the diagnosis, with
exactly one line per case naming the wait that hung.
That last part needed a fix of its own: an abort listener left behind by a wait that had
already resolved printed its stale reading at a later wait's timeout, so every timeout spoke
with more voices than it had hung waits. The listener is dropped on the way out.
In-frame `frame.click` needs the utility world too and is left alone: a main-world click is
not a user gesture, and the gesture is what those cases assert on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): take the preview's refusal from the browser's report, not from a listener in the frame
The utility-world theory is refuted: CI's Chrome answered `utilityWorld "resolved"` on both frames
and the case failed anyway, with the widened frame reporting the artifact parsed, the CSS
background's `img-src` refusal recorded, no `script-src` refusal, and no script run. Two different
things produce exactly that reading. The policy refused the script and the frame's own listener was
not there to see it, or the sandbox refused it first, which raises no violation at all -- and a
listener inside the frame cannot tell them apart, because in the second case there is nothing for it
to hear.
So the evidence moves to where neither depends on timing: the sealed server now appends `report-uri`
to the policy it serves, carrying the arm's nonce, and the rig records what the browser reports. The
override arm's precondition is a `script-src` report from this arm's frame, waited for under
`ctx.signal` and ending in the diagnosis. Measured on both engines: a widened frame is reported for
`script-src` and a sealed one never is, while both are reported for the image the policy refuses. So
the sealed arm now waits for its own `img-src` report, which turns "no script-src refusal here" from
an unguarded absence into one measured beside a presence.
`report-uri` is additive -- it names where a report goes and changes nothing about what is enforced
-- and the first case now pins that by splitting the served header and asserting the rest is the
shipped Kotlin text exactly.
The in-frame collector stays, for the diagnosis only, and it now carries the readings that would
have answered the ordering question in one run: the init script records when it ran in each frame,
the artifact's script records the same on the document element, and the diagnosis prints both. What
the artifact wrote moved off `window` entirely for the same reason -- a page init script owns the
window of every frame it reaches. Locally the init script precedes the artifact's by one
millisecond, in every arm on both engines; the ordering on Chrome 152 is now a reading rather than a
hypothesis.
A measurement worth keeping beside the code: in a frame with no `allow-scripts` the init script runs
and its array exists, and no violation event is ever delivered to it, while the browser reports the
same refusals to the server. That is why the old `violations` assertions could not have caught this.
Red-first, all three locally: with report recording off, with the report endpoint not appended, and
with `script-src` reports alone dropped, the preconditions time out into the diagnosis and the
served-policy assertion reds too. 18 of 18 on both engines, three runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): cover the navigation wait's sampling branch, and bind the load wait to the case
Two findings from the bots on the rig, both real.
The navigation wait's five-second sampling branch called `describePreviewFrame` after the import
that supplied it had gone. It fires only when an arm is slow, the name is evaluated before `.catch`
can attach, and `no-undef` is off, so nothing in the file or the lint run had ever executed that
line. Fixed by moving the settle waits into the readiness module, where the call sits beside the
import it needs rather than a file away from it -- the split is what let the reference dangle.
The proof is a case that drives the branch: a navigation the arm will never see, a sampling interval
passed in, and the case's own abort ending it, asserting on the reading it printed rather than on
its own absence of an error. Red-first, with only that branch's callee renamed: 2 failed, 18 passed,
`ReferenceError`. So the case covers the branch and nothing else in the file did.
The load-only arm's `frame.waitForLoadState('load')` was the one wait left that did not observe
`ctx.signal`; after an abort it kept waiting on its own timeout. It is a main-world poll on
`document.readyState` now, re-resolving the frame each attempt like every other wait here, and it
ends in the diagnosis.
20 of 20 on both engines, twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
5fed670a50 |
fix(rebuild): refuse to compile node-pty source that lacks the MSYS breakaway denial (#21968)
The addon gate already rejects a conpty.node without the L"msys-2.0.dll" marker, in the Electron probe and after the rebuild. But the rebuild compiles whatever node_modules/node-pty holds, and pnpm only materializes that from the patch at install time. On a Windows dev checkout whose node_modules predated the denial, --force compiled for minutes, rewrote conpty.node byte-identical and unpatched, and the gate then advised "rebuild from source" -- the step that had just run. Read src/win/conpty.cc before compiling. If it lacks the literal, stop before the rebuild and say to run pnpm install, which re-applies the current patch. An absent source file is not judged; the addon gate still reads the binary. |
||
|
|
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 |