mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
c4e58fac56cb14138c80d52dbca4ba296dce084e
854
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c4e58fac56 |
fix(mobile): restart the streamed browser pane on every return to the app (#22694)
* fix(mobile): restart the streamed browser pane on every return to the app The browser pane stops its screencast when the app leaves the foreground and starts a new one when it comes back, keyed on an `appActive` boolean. When the leave and the return are handled in one React render (the JS thread was held across the whole trip, as a suspended or frozen app is), React applies false-then-true as no change, so the stream effect never re-runs: the old subscription is kept and no new one starts. If the desktop ended that subscription while the phone was away (it evicts a viewer whose socket refuses 90 frames in a row), the pane shows the last frame it had indefinitely, with no error and nothing that would restart it. The pane now keeps `foregroundVisit`: null while the app is away and a new id on each return. A batched leave-and-return still moves it to a new value, so every return starts a fresh stream, and the host's start snapshot repaints the pane. * refactor(mobile): drop a busy reset both stream-effect branches overwrite |
||
|
|
9f7f406b57 |
test(mobile): repin the RPC recording corpus and session closure after #22392 (#22702)
* test(mobile): repin the RPC recording corpus to main after #22392 #22392 pinned baseline to a branch commit ( |
||
|
|
060743d813 |
fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps (#22392)
* fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps
Frame pacing. The pane decodes each frame on a hidden layer and flips to it on
onLoad. While one frame decoded, every newer frame re-pointed that same hidden
layer, which cancels the in-flight load. On a phone that decodes a frame
slower than frames arrive (~10/s during page loads, menus, spinners), onLoad
never fired for any of them and the pane sat on an old frame until the page
went still. A decoding layer is now never re-pointed: only the newest frame is
held, and it takes the layer once the decode settles. A 1.5s watchdog frees a
layer whose decode never reports, and a frame the hidden layer already holds
(a blinking caret alternating two frames) flips at once, since an unchanged
source reloads nothing.
Double taps. When browser.mouseClick failed, the pane replayed the tap as
move/down/up. On a timeout the click is still queued on the host, so the
replay landed a second tap on whatever the first one opened. The replay now
runs only when the click definitely did not reach the host.
* test(mobile): re-record the corpus without the timed-out tap replay
The corpus certified the move/down/up replay after a transport-rejected
browser.mouseClick, which the commit before removes. Scoped like #22179:
baseline bumped by editing that one line, then --record.
788 files. Every changed line classified:
- `baseline`: 787 files (786 goldens + pilot-scenarios.json), nothing else.
- matrix-browser.pointer-click-browser.mouseclick-1.json: the
transport-rejection and transport-rejection-no-message partitions of
browser-pointer-click-fallback now send only browser.mouseClick#1. The
refused partitions still replay, unchanged.
* test(mobile): move the session closure pin past #22452's main-agent-status modules
#22452 changed only src/shared, so its CI never ran the page-closure suite; main
now measures 4220 modules (1034 local) against a pin of 4218. This branch adds
nothing to the closure: its own count matches main's.
* docs(mobile): say a re-pointed decoding layer loses its onLoad, as measured on Android
* refactor(mobile): give the streamed browser pane's double buffer one owner
The frame pacing, decode watchdog, layer flip and reset were spread over three
hooks and a helper module, wired back through the stream hook and the pane.
They now live in one plain pacer (browser-frame-pacer.ts) with one timer, and
the pane binds each layer's View/Image straight to it.
Behaviour fixed on the way, each with a failing test first:
- A slow last frame with nothing newer queued was abandoned by the watchdog and
never shown. The decode deadline now only applies when a newer frame waits.
- Any pane re-render re-pointed both layers at the newest frame behind the
pacer's back, so a blinking caret froze. The Image source prop is now only
the mount-time frame; every later source write is the pacer's.
- A frame that failed to decode left its layer marked as holding it, so an
identical frame flipped to an undecoded layer. Giving up on a decode now
clears the layer's source.
- A native onLoad for a source the layer has since moved off could flip early.
The flip now checks nativeEvent.source.uri, which Android and iOS Fabric both
report as the raw source string; RN Web's own load event has none, so the web
flips only through its decode probe.
The session closure pin drops by the two modules this removes.
* refactor(mobile): send the tap's mouseClick directly instead of through a flag
The delivery-unknown check was a mutable flag set inside the request callback.
The click now calls browser.mouseClick itself in a try/catch: a delivered click
returns, a delivery-unknown failure returns without replaying, and a refusal or
null result still replays as move/down/up. Same wire traffic; the corpus
certifies it unchanged.
* fix(mobile): never cut a streamed frame's decode short
The 1.5 s decode watchdog abandoned a slow decode whenever a newer frame was
queued and re-pointed its layer. On an Android emulator under load that is the
original freeze again: noise frames decode in 2-10 s, every abandon starts a
decode the next abandon cuts, Fresco reports the superseded loads (30 stale
onLoads in one run) and the pane showed 11 of 41 applied frames. Without it,
the same run flips every applied frame (16/16, no stale load), and a slow last
frame is shown in every cycle.
Nothing else needs it: with the layer never re-pointed mid-decode, native
answers every load with onLoad or onError, and the web probe's decode()
always settles. The pacer keeps one timer, for the interval.
* fix(mobile): track what each frame layer's Image holds, so no write goes unanswered
The pacer cleared a layer's source when it gave up on a decode (a reset
mid-decode, or a failed decode) while the native Image still held it. The
next identical frame was then written again, which is a native no-op on
Android (ReactImageView.setSource returns on equal sources) and iOS
(ImageShadowNode skips equal requests): no onLoad, no onError, and the pane
stayed frozen until the stream restarted. Returning from the background to
an unchanged page is enough to trigger it.
Each layer now records the source its Image holds and whether that source
has answered (loading, ready, failed). A layer is written only when it is
not loading and only with a different source, so every write gets exactly
one answer. A reset no longer abandons anything; the load under way still
answers for its layer. A frame the hidden layer already holds flips at once
if it decoded and is skipped if it failed.
The pane test's native model now treats a same-source write as a no-op and
answers each change once with the layer's current source; both new cases
(reset mid-decode then the same frame, failed decode then the same frame)
freeze on the previous head.
Also, per review: the pacer no longer touches busy or metadata. The stream
hook creates it and receives each frame as it goes on screen, so metadata
(and with it touch mapping) now follows the visible frame rather than one
still decoding.
* test(mobile): hold the pane test's AppState listener without a type assertion
* fix(mobile): never replay a tap the host answered
A fulfilled browser.mouseClick ran on the host, but a null result still
replayed it as move/down/up. The native bridge always answers { clicked },
while the external-Chromium provider returns agent-browser's `data` as is,
which can be null, so a right-click there was a double tap. Only a refusal
that is not delivery-unknown now replays.
* test(mobile): re-record the corpus for the answered-tap rule and rename its checkpoint
The commit before stops replaying a tap the host answered with a null
result. The seed's checkpoint was named clicked-by-fallback, which several
partitions no longer do, so it is renamed tap-settled in the same record.
Baseline bumped to
|
||
|
|
7a4f080086 |
revert: #18790 (orchestration incarnation reap fallback and bundled Freebuff agent) (#22601)
This reverts commit
|
||
|
|
89817ad2b4 |
test(mobile): repin the recording corpus to main's tip after #22570 (#22576)
#22570 pinned its own branch commit, which the squash left off main; the corpus now pins main at
|
||
|
|
37820f9683 |
feat(mobile): the page owns its safe area, like a native screen (OTA phase C follow-up) (#22570)
* feat(mobile): the page owns its safe area, like a native screen The shell reserved both system-bar strips outside the WebView and painted them bgBase, so every page screen showed a flat band above its header, sheet scrims stopped short of the status bar, and the dock floated above the gesture bar. For a page that declares `safe-area-insets` in `ready.accepts`, the shell now draws the WebView edge-to-edge and keeps only the keyboard strip off it. `init` carries the insets the view sits under (bottom 0 while the keyboard ends the view, top 0 under the update banner), and a move is re-sent over the existing route-update `init`. An older page keeps the reserved strips, since it has no reader for the insets. On the page, a root layout (`app/_layout.web.tsx`) feeds those insets to react-native-safe-area-context below ExpoRoot's env()-measuring provider. It also replaces expo-router's DefaultNavigator, an all-edges SafeAreaView that padded a second time. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): re-measure the page pins for the root layout The page's route tree gained `./_layout.tsx` (its web sibling of the native root), so every pin that counts the tree moved: - Script sweep re-measured by building `routes.slice(0, n)` for each n. It reads 69 scripts at 16 routes, which matches the real build. The asset-ceiling crossing moves from 31 routes to 32. - Route closures now enter through both layouts. `entryNames` gains `[dir]` because `app/_layout` and `app/h/_layout` share a name. - Session closure pin 4216 -> 4219. The added modules are bridge-safe-area-insets, page-safe-area-provider and _layout.web. - The web-overrides allowlist names `app/_layout.web.tsx`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read safe-area ownership from the page-document state Review fixes on the page-owns-safe-area change. - Ownership is page-document state now. `page-ready` carries `accepts` beside `reports`, the patch sets `pageOwnsSafeArea` from `safe-area-insets`, and the session hook projects it like `backClaimed`. The screen's own per-session copy is gone. - Insets moves re-send `init` only to a page that declared `safe-area-insets`. A page that took route updates but not insets was sent a useless `init` on every keyboard show and hide. - The banner wrapper is gone. The root pads the status bar strip while the banner shows. - The shell session defaults the insets inline, with no predicate that mutated its argument. - The provider is folded into its single caller, `app/_layout.web.tsx`. The session closure pin reads 4218 (local 1032). - The screen tests share their module mocks, and the safe-area cases move to a suite of their own: owned page, banner, iOS and Android keyboard, and an older page that gets no re-init. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
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 |
||
|
|
069dc8a1d8 |
feat(agent-launch): let a caller reserve the chat session, and start terminal launches with the session picks (#22523)
* feat(agent-launch): let a caller reserve the chat session and carry session picks to a terminal launch * fix(agent-launch): keep a caller-minted session id named for its agent, and mint the fallback the same way * test(mobile): model the older host from the launch fields, not the refined schema * docs(agent-launch): describe the reserved session id as conversation identity, not placement The caller mints the session id so it knows which conversation it started; tab placement is not keyed on it. Also puts the terminal surface's doc comment back on createTerminalSurface. * docs(agent-launch): say a terminal launch reads the session picks on the wire contract The `sessionOptions` field doc still said a terminal launch ignores them, which this branch changed. * fix(agent-launch): check a reserved session id's token after the agent name, not the whole id A hyphenated agent name failed the one-token check, so any session id for such an agent was refused at the wire, while every other agent without a chat has its id ignored on the terminal. |
||
|
|
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 |
||
|
|
b864a1c775 |
test(mobile): repin the recording corpus to main's tip after #22381 (#22407)
#22381 pinned its own branch commit, which the squash left off main; the corpus now pins main at
|
||
|
|
996f9cc306 |
feat(mobile-web-bundle): gzipped 384 KiB ranges over a capability-negotiated mobileWeb.bundle.range (OTA phase C follow-up) (#22381)
* feat(mobile-web): serve gzipped 384 KiB bundle ranges behind a capability Adds mobileWeb.bundle.range with its own strict params and result, so shipped chunk readers see no reply change. The host gzips each range at level 6 and sends identity when gzip does not shrink it, sharing the chunk method's read-slot budget and per-asset verification. status.get advertises mobileWeb.bundle.range.v1 beside mobileWeb.bundle.v1, and the method is allowlisted for paired phones. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read the bundle range capability and range replies Adds the range reply reader and operation, and picks range or chunk from the status.get capabilities the connection already proved, so an older desktop keeps being paged in chunks with no probe round trip. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): keep four bundle chunk reads in flight across the whole manifest The fetch ran one worker per asset and paged inside an asset sequentially, so the largest script's 71 chunks were 71 serial round trips while the other readers idled. One window of four chunk reads now covers every (asset, offset) on the host's chunk grid, largest asset first. A read_limited refusal narrows the window and retries the read; eof is still read from the reply. Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): add fflate 0.8.2 for gzip bundle ranges Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): decode gzip bundle ranges into a bounded buffer Inflates each range into a buffer one byte past its window, so a gzip bomb costs at most that allocation and an overlong body is visible. A corrupt, truncated or unknown-encoding body refuses as range-undecodable; a body of the wrong decoded length refuses as range-length-mismatch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): pass the bundle read method from the session to the fetch The download reads the capabilities of the gates the reducer decided under and hands the fetch range or chunk. The fetch does not act on it yet; the range read lands on the pipelined window. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the bundle-fetch family under pipelined reads Baseline moves to |
||
|
|
98a6a5325c |
test(mobile): repin the recording corpus to main's tip after #22376 (#22394)
#22376 pinned its own branch commit, which the squash left off main; the corpus now pins main at
|
||
|
|
0c2514e7e0 |
perf(mobile): keep four bundle chunk reads in flight across the whole manifest (OTA phase C follow-up) (#22376)
* perf(mobile): keep four bundle chunk reads in flight across the whole manifest The fetch ran one worker per asset and paged inside an asset sequentially, so the largest script's 71 chunks were 71 serial round trips while the other readers idled. One window of four chunk reads now covers every (asset, offset) on the host's chunk grid, largest asset first. A read_limited refusal narrows the window and retries the read; eof is still read from the reply. Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the bundle-fetch family under pipelined reads Baseline moves to |
||
|
|
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> |
||
|
|
11083ac4d3 |
fix(mobile): the page's auth-failed banner offers Re-pair again (#22363)
#22283 dropped all three banner actions on the page, on the claim that /pair-scan sits outside the page's route root so Re-pair cannot work there. It does work: the host screen's router is the route handoff, which posts a target the page does not serve to the shell (route-handoff.web.ts:207), and on the emulator the tap opened the native scan screen and Back returned to the same page document. The page's sibling now renders Re-pair as native does, wired to the same onRepair, plus a muted line for the two it still cannot honour: "Reconnect or remove this host from the Orca app." forceReconnect stays null on the page and removal keeps refusing; native renders its three actions as before. The doc comments and the web-overrides reason are corrected, and the reason's drifted citations re-resolved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d11b3e226d |
fix(mobile): page Back with a dirty markdown draft opens the unsaved-drafts prompt (#22362)
The session's Markdown actions registered BackHandler natively only, so on the page an unsaved draft left the key unclaimed and the shell's pop dropped the edits without the prompt. The hook now claims through useBackClaim on both platforms: always natively, where leaveSession replaces to the host at the root instead of exiting the app, and on the page only while a draft is dirty, since an unclaimed press there is already the shell's own leave. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3f9582bc90 |
fix(mobile): removing a host deletes its page cache through the one process store (#22352)
* fix(mobile): removing a host deletes its page cache through the one process store Host removal never deleted the removed host's page generation cache, so it sat on disk until four other hosts were activated and a re-pair could reopen the old tree. Every caller also minted its own GenerationStore with its own queue, so a removal's index or update-failure-log write could drop the mounted session's write landing inside it. The store is now one per process (processGenerationStore, with a reset-for-tests seam); the shell runtime, host removal and Troubleshoot all share it, and removal deletes the host's cache after the metadata commits, fire-and-forget beside the failure forget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the cold-start store per mount is the test's, not production's Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
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 |
||
|
|
66ade30576 |
fix(mobile): the page's live input stays above the soft keyboard (#22252)
* fix(mobile): the page's live input stays above the soft keyboard Edge-to-edge makes the manifest's `adjustResize` inert, so the window never shrinks for the IME and the page's `visualViewport` reads full height with the keyboard up: the session route laid its live input row out under the keys. The shell owns the window, so it shortens the WebView by the keyboard instead. The session screen's own `Keyboard.addListener` pair never fired on react-native-web, so the page also never held off the terminal refit. Both facts now come from the platform seam, which answers them separately on the page: the keyboard is open, and it covers nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the session keyboard harness without a cast The hoisted keyboard now carries the seam's own `SoftKeyboardState`, and the mocks the screen is handed carry the types it calls them with, so nothing is asserted into shape. The scope the harness builds could not be spelled at all before: the hook took the whole lifecycle model to read 28 of its fields. It now names those fields, which the model still satisfies, and the test builds one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3d76c22b57 |
fix(mobile): a refused update serves the cached generation while the host is up (#22237)
* fix(mobile): a refused update serves the cached generation while the host is up A newer generation that fails to fetch or stage was refused with a named reason and then painted a wall with "Try again" over an intact generation already on disk — the same one the offline branch opens without being asked the moment the host goes away. `onDownloadFailed` now branches on what is cached rather than on which side refused: with nothing on disk the refusal is still the screen, and with a generation on disk it is opened through the offline branch, judged by its own route list. The refused generation is never staged, committed or persisted, and nothing about the refusal is written, so the next launch asks the host again. The bundle-side refusal is named as a dismissible notice above the page, on the existing host-route banner. It says what happened and promises no retry, because the shell schedules none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): judge the cached generation against the host it can reach before serving it The fallback served a cached generation on the strength of the offline rule, which skips the compat check because a host nobody can reach cannot have changed. On this path the host has just answered, and an update usually exists precisely because it moved — so bytes that were inside the protocol window when they were written may be outside it now. `CachedGeneration` now carries the three fields the compat verdict reads, projected in `openCache` off the manifest stored beside the assets. That manifest is never absent: `readActiveGeneration` answers null for a generation whose manifest did not parse, and the read schema requires all three. `cachedGenerationWall` lives beside `gateVerdict`, because only an `open` gate is judged further. The other verdicts already have answers there: an absent capability is the native-route rule, and an unreadable status leaves the same empty list, so walling on either would be the `bundle-unavailable` wall that file exists to keep off a host that simply did not reply. A generation outside the window now earns the wall with its verdict, not the download-failed screen, and nothing is deleted: the bytes are intact and a newer host is not what makes them wrong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ask the cached generation's own routes before walling it The compat wall ran before the route question, so a cached generation that does not carry this route — or predates route listing entirely, `routes: undefined` — earned a terminal `bundle-incompatible` wall where the answer is `native-route`. `onManifestRead` has always taken the other order: a route that stays native has nothing to wall about. `openByOwnRoutes` now asks the route first and applies the wall only on the served branch, and the update notice moved to `served`, so a native answer carries no notice about a screen it is not showing. The other half is the verdict the gates hold at the moment of the refusal. A `fetching` session does not await the gates, so a refusal can land under a verdict the flow never started on. `gateState` is now the one mapping from a gate verdict to a screen, shared by the entry into the flow and by the fallback, so the two cannot answer the same verdict differently: a host that stopped serving a bundle is `native-route`, a status that went unreadable says so and re-arms, a dial in progress or a pending status waits in `checking`, an unreachable host keeps the offline rule and serves the cache unjudged, and `open` is the only answer that leaves a host to judge the generation against. That inverts two round-2 assertions that expected the cached page to be served when the capability list had gone empty. Both were wrong for the same reason: an empty list is the gate's question, not a verdict about a bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): announce the host route notice banner, as loudly as its tone The banner is inserted into a screen that is already on screen, so a reader who has moved past the top of the list never arrives at it. It carried no live region and no role, so nothing carried it to them. The urgency follows `tone` rather than being assertive for everything. The failure tone is an action that did not happen — a refused worktree action, or the shell's refused update — and interrupts with `alert` and an assertive region. The notice tone is a bounced route, context for a list already being read, and waits its turn politely; interrupting for that would train people to ignore the first. No role on that arm: React Native has no `status` role, so the polite region is the whole of the answer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
0748915ce2 |
fix(mobile): a refused dictation start says why (#22256)
The composer had two dictation failure handlers, one policy written twice. `onError` routed a setup-required refusal to the dictation setup sheet; `startDictation`'s own catch — the only path a refused `speech.dictation.start` takes — did not, so a desktop whose voice settings are off or whose model dir is empty answered the tap with its internal code as a toast. Both entry points now call one `reportDictationFailure`, so the sheet opens for `voice_dictation_disabled`, `voice_model_not_selected` and `voice_model_not_ready:*` whichever path saw them, and every other refusal keeps the toast and haptic it had. The desktop's error already carries its reason in the message, so no wire change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
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 |
||
|
|
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 |
||
|
|
faf7567df5 |
fix(mobile): the catch-all carries a deep link's query, and reads a trailing rest-segment pattern (OTA phase D, C8 carry-forwards) (#22180)
* fix(mobile): carry a deep link's query through the catch-all switch The catch-all built its pathname from the `page` segments and dropped everything else, so /h/x/session/y?tab=files reached the page as /h/x/session/y. Every named switch passes its params explicitly; this one cannot name the keys, so it takes everything useLocalSearchParams merged in that is not one of the two segments the pathname is built from. firstParam per key, as the named switches do, because init.route.params is one value per name and a repeated key would cross as `a,b`. An empty value is kept: a named switch drops its own because it knows what its screen makes of one, and this switch knows no screen. Over the schema's param ceiling shellScreenRoute answers null and the switch refuses, which is the same fail-closed answer it gives a pathname the bridge refuses. Red first: five of the seven new cases fail on the old body — the carry, the empty value, the repeated key, the encoding round trip through shellScreenRouteKey, and the ceiling refusal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read a manifest route pattern with a trailing rest segment matchesRoutePattern was segment-count exact, so `/h/[hostId]/[...page]` read as an ordinary dynamic segment and matched exactly one segment. Nothing on the desktop declares such a pattern today; a desktop that ships a catch-all screen later writes one into its manifest, and a phone already in the store has to read it, which is what OTA is for. A trailing rest segment now matches one segment or many, never zero, and never an empty one. One or more and not zero is expo-router 55.0.18's own answer for the same pattern: getReactNavigationConfig.js turns [...page] into *page, fork/getStateFromPath-forks.js turns that into ((.*\/)), and cleanPath beside it gives every path a trailing slash, so the tail must hold a slash of its own. Measured against both functions directly. A rest segment anywhere but last, or a second one, is refused by name (rest-segment-not-last / rest-segments-repeated) and matches nothing, so such a route stays native rather than being guessed at. grantsForRoute now prefers an exactly declared route over a rest one for the same pathname: a rest pattern covers everything under its prefix, and first-declaration-wins would hand a screen with its own row whatever the catch-all asked for. No schema change. Both manifest readers already accept the pattern string -- the host's ROUTE_PATHNAME_PATTERN allows brackets and dots, and the phone's is a bounded string -- and that is pinned rather than assumed. Red first: seven of the twelve new cases fail on the old matcher. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the one place the rest-segment rule narrows expo-router Its own matcher takes a non-trailing rest — h/*page/tail matches /h/a/b/tail at 55.0.18 — and this refuses it. Written where the refusal is, so the divergence is read when it changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the fragment key from the catch-all route's query expo-router's parseQueryParams seeds params['#'] from the URL fragment before it reads the search string, so /h/x/session/y#files reached the switch with a `#` key and crossed as params: { '#': 'files' }. A fragment is not a query and the route the page is given carries none. Red first: both new cases fail on the old filter — the fragment-only link lands a params key at all, and the mixed one carries '#' beside `tab`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
0dae510d04 |
test(mobile): repin the recording corpus to main's tip after #22179 (#22185)
The rejection-golden PR pinned its own branch commit, which the squash left off main; the corpus now pins main at
|
||
|
|
bfcbc6feee |
fix(mobile): contain the older-history page's rejection at its own boundary, and re-record its family (#22179)
* fix(mobile): contain the older-history page's rejection at its own boundary
`loadEarlier` in `use-mobile-native-chat-session.ts` runs its read inside a
fire-and-forget `void (async () => {...})()` with a `finally` and no `catch`, so
every throw inside it reaches the document as an unhandled rejection. Two
mechanisms, both certified by
`matrix-session.native-chat-page-nativechat.readsession-1`:
- The awaited `nativeChat.readSession` request rejecting — a transport drop
(`transport failure`, and the empty-message shape) or the client abandoning
the in-flight page at teardown (`Connection closed`).
- `'error' in result` on an accepted success whose result was absent or null.
The operation's reader is `z.unknown()` on purpose, because the reply is a
union, so `rpcSuccessResultOrSkip` accepts `undefined` and `null` as results
and `in` throws a TypeError on both before the page is ever read.
Same class as the diff-comments fix (#22111) for the first mechanism and the
catch goes at the same boundary — nothing awaits this promise, so there is no
caller a swallowed rejection could hide from. It differs in the second: that
TypeError is a defect in the payload read rather than an uncaught rejection, so
it is fixed by checking the shape before `in` rather than by the catch, which
would only have hidden it.
Neither mechanism moves an observable value: the `finally` already cleared
`loadingEarlier`, and both new returns land where the throw did, before the
window is touched. A refused page and a failed page now leave the delivered
window alone, which is this operation's declared skip policy.
`mobile-native-chat-page-rejection.test.ts` captures the process
`unhandledRejection` handler for the run and reads it; the three cases fail on
main with exactly `transport failure` and the two `Cannot use 'in' operator`
TypeErrors.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the corpus without the older-history page's rejections
The corpus certified the five unhandled rejections the commit before removes, so
the golden had to move with them. Scoped the same way as #22111: `baseline`
bumped by editing that one line, then `--record`. The recorder refuses to run on
any other tree, and the pin guard's `reproduce` replays the corpus against the
pinned tree with only the recorder overlaid, so a product fix cannot be recorded
without the bump.
788 files, 824 insertions, 938 deletions. Every line classified:
- `baseline`, 787 files (786 goldens + `pilot-scenarios.json`) and nothing else
in any of them. One distinct changed line across all of them.
- `matrix-session.native-chat-page-nativechat.readsession-1.json`, the only
golden with a substantive change (37 insertions, 151 deletions). Its value
pool goes 34 -> 24: ten entries leave, none is added, and no surviving entry's
content changed.
Inside that golden, three things move and each follows from the effects going:
- **Effects.** Five `unhandled-rejection` values leave the pool — the two
TypeErrors (`02fb8ef75b28` undefined, `74a36d9b608f` null), the two transport
rejections (`b5f9fdfa5b32` transport failure, `b388251f574a` empty) and
`14095708382e` Connection closed — and the 16 checkpoints that carried them
now read `"effects": []`: four each on `result-absent`, `result-null`,
`transport-rejection` and `transport-rejection-no-message`.
- **One checkpoint is gone.** `native-chat-page-earlier.prelude:cleanup`.
`run-recording.ts` emits `cleanup` only when teardown produces an effect, and
the abandoned page's `Connection closed` rejection was the only one it
produced. Nothing left to observe, so no checkpoint. The two values only that
checkpoint referenced leave with it: `980096177097`, its rejected
`nativeChat.readSession#1` settlement, and `91a7795fa0ab`, its
`nativeChat.unsubscribe#1` frame.
- **Renumbering, on `payloads` at 12 checkpoints.** Ordinals are one counter per
run, so dropping an effect shifts everything after it: `nativeChat.subscribe#2`
5 -> 4, `nativeChat.unsubscribe#1` 6 -> 5, `nativeChat.unsubscribe#2` 7 -> 6.
Each shifted frame is byte-identical to the entry it now points at apart from
the ordinal, and every one of those entries was already in the pool — the arms
that never rejected recorded them. That is why nothing is added and the three
higher-ordinal twins simply go unreferenced.
`state`, `sender` and `settlements` are unchanged on every surviving checkpoint,
so no observable value moved. `recorderSha256`, `adapterSha256` and
`scenarioSha256` all hold, and `HEAD_EFFECT_SHA256` in
`mobile-session-route-parity.test.ts` does not move: that pin walks
`MobileSessionRouteScreen` and its named expansion hooks, and
`useMobileNativeChatSession` is in neither set.
`baseline` is a branch commit, so a repin to main follows the squash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): build the page-rejection client double with one assertion
The changed-code quality gate read two assertions in the new file and the
second carried no rationale. There is only one shape here the types cannot
express, so there is only one cast: `RpcSuccess` requires `result`, which is
what makes the matrix's `result-absent` reply untypeable. The sender goes into
the object literal the cast already covers.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the corpus to the head its product tree matches
The commit before moved a file under `mobile/src`, and the recorder guards that
whole tree rather than only what it loads: `scripts/rpc-recording.mts` refuses
to run when `git diff <baseline> -- mobile/src src/shared` is non-empty. Left
alone, the pin would name a tree that still reproduces the corpus but that no
one could record against without repinning first.
788 files, 788 insertions, 788 deletions, and `baseline` is the only distinct
changed line in any of them. Nothing else moved, which is also the determinism
check: a second full record against a product tree that differs only by a test
file the drivers never load reproduced all 787 goldens byte for byte.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
ed909846f6 |
test(mobile): an offline cold start reads the grants of the last manifest accepted (OTA phase D, finding 7) (#22175)
* refactor(mobile): share the generation store's in-memory disk The fake answers the file-system port the store is written against, and the offline-grant suite drives the same real store against it. Two in-memory trees answering one port differently is how a passing suite stops being evidence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): an offline cold start reads the grants of the last manifest accepted Finding 7 on #21503 end to end: the reducer, the runner, the real generation store and a disk, with only the network and the platform modules doubled. A grant-only edit keeps the bundle's build id, so the cached assets are right under a manifest an edit behind; the verdict measured here belongs to a second mount whose whole evidence is the tree on disk. Both directions of the edit and both arms: a widened grant list reaches the offline verdict, a revoked one is gone from it, and a manifest read under a wall is not written, so offline keeps the last grants this shell could use. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the extracted fake's type at its import The store suite still spelled the local type name. `tsconfig.json` excludes test files, so only the tests-typecheck ratchet sees this. 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 |
||
|
|
5769eb6724 |
fix(mobile): a granted microphone tap launches no permission activity, and an aborted start says so (#22150)
* fix(mobile): a granted microphone tap must not launch the permission activity
Every Android mic tap raised GrantPermissionsActivity although RECORD_AUDIO
was granted: Expo's askForPermissions path has no granted check and goes
straight to delegateRequestToActivity. The activity pauses and resumes the
React host for 50-115 ms.
The module now answers from getPermissionsWithPermissionsManager when the
permission is already held. iOS needs nothing: EXPermissionsService returns
on a granted status before it reaches the requester.
The resume behind that activity forces a tabs reconciliation, which can
transiently clear the active handle and so flip canSend, the hook's `enabled`.
That reached cancel() through the !enabled Effect -- the same cancel the user's
own has -- so a tapped start ended at idle with nothing shown. abandonDictation
now carries a reason: null is the user's cancel and stays silent, anything else
reaches onError when a start or a recording was underway.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the dictation device log instead of asserting it
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): the input-closed test names what is established and what is open
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): a dictation abort must not write over the start that replaced it
abandonDictation read what it would report before awaiting the desktop cancel,
and nothing re-checked identity after it resumed. A start landing inside that
round trip reaches recording, then the stale abort reported the closure over it
or idled it. It now captures the generation it bumps to and returns if the
generation moved, the guard the finish check and the desktop start already use.
capture.open()'s catch had the same hole: a rejection arriving after a disable
had already reported the closure ran applyStatus('idle') and rethrew, wiping it.
The catch now makes the same check the arm below it makes, and returns without
rethrowing, because the composer toasts whatever start rejects with.
Both races predate this branch; probed against
|
||
|
|
5064469687 |
fix(mobile): a same-build cache hit persists the fresh manifest (OTA phase D1) (#22139)
* fix(mobile): persist a fresh manifest onto the generation on disk A route-grant edit on the desktop moves no asset, so the bundle is published under the build id it already had: the cached generation holds the right bytes under a manifest an edit behind, and that stored manifest is the whole of an unreachable host's verdict. The store gains one operation for it. Refused unless the manifest names exactly the bytes on disk — same build id, and the same asset list read through the contract's own serializer, which is the string the id is a digest of. Written beside and renamed over, so a write that fails leaves the manifest the assets were downloaded with. A refusal is a return value, never a throw: it costs freshness, never the generation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the manifest through on a same-build cache hit The same-build hit in `onManifestRead` opens the cached generation and now also asks the store to rewrite the manifest beside it, and the generation the session holds carries the fresh routes from that point. Without it every offline verdict lagged a grant edit: `onCacheRead` reads the stored routes, and nothing on this path wrote them. The manifest travels whole on `manifest-read`, because the store compares its asset list and a projection rebuilt from the fields a transition reads would name other bytes. The fallback fixture that read a newer manifest under the cached build id now uses a build of its own, which is what makes the download it is about happen at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): every same-build manifest read persists, whatever the route verdict (round 1) A verdict about this route is not a verdict about the manifest. A fresh list that takes this screen native, or that names a bundle this shell cannot open, still grants or revokes the other routes the same assets serve — and what is stored beside those assets is the whole of the next offline verdict. Both returns left it unwritten, so an offline entry kept grants the desktop had already taken away. The same-build read is now taken before the route verdict: the native and wall returns carry the fresh routes on the generation they hold and emit the persist, exactly as the open does. The different-build arms are untouched, wall included, which still fetches nothing. `readMobileWebShellReachability` moves to `mobile-web-shell-reachability.ts`, the module its test was already named for: the reducer was one line under the 300-line cap and this fold needed the room. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the manifest swap crash-safe at the store (round 2) `moveFile` deletes the destination before it moves, because expo offers no atomic replace, so a failure after that delete left a generation with no manifest at all — which reads back as "no activation" and drops the host's cache. A phone whose host is unreachable loses its whole workspace to one interrupted rename. The store's fake threw before that delete, so the suite never exercised the ordering the adapter really has. The write is now three steps inside the generation directory: the fresh manifest to `manifest-next.json`, the old one away, the fresh one over it. The old manifest is never deleted before the whole of the fresh one is on disk, and `readActive` settles every window it leaves — both files present finishes the swap, a pending manifest alone is adopted, and one that is torn or names another build is discarded with the old one kept. A pending file that cannot be settled deletes nothing, because it may be the only manifest left and the next read can still adopt it. The asset-path guard refuses the pending name too, or an asset could be read back as an activation. `joinUri` moves to a module that imports nothing: taking it from the file-system module pulled `expo-file-system` into everything that addresses the cache, which is the import the store's testability rests on not having. The refusal rule now names where the id-to-assets refine actually lives. It is on the host's strict schema, not on the loose reader the phone parses with, so the store's asset comparison is the phone's own check rather than a restatement of one already made. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a walled same-build manifest is not persisted (round 2) Round 1 wrote every same-build manifest through, the walled ones included. That is the one read where the fresh manifest must not reach disk: an offline entry skips the compat check, so a stored manifest this shell has just declared it cannot read would have the next offline entry open a page under the grants of that bundle. What is on disk stays the last manifest this shell accepted, and the held generation keeps its routes with it, so the session's record still matches what was written. The native-route arm keeps the write: a route this build cannot serve is not a bundle it cannot read, and the fresh list still governs the other routes those same assets serve. Its round-1 test is now a control that pins the wall persisting nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the phone refuses a manifest whose build id is not its asset digest (round 3) The id is a cache key and a claim about content at once, and only the host checked the two against each other. The phone read the same document loosely and never recomputed the digest, so a stale or forged id reached the shell — which treats an id it already holds as the same bytes and opens the generation on disk without paging one. The reader now carries the host's refine, so a manifest that reaches the reducer under the cached build id names the same serialized asset list by construction. The two checks in that reader are one `superRefine` with the cheapest first and the first issue returning: the digest is the only one that hashes, and a manifest already over the allocation ceiling must not be hashed to be refused. The store's comparison stays, as the second reading of one rule rather than a rule of its own: its argument is a plain object, and nothing in the type says which parse it came from. The fixtures that published a literal id now derive it from the assets they name, which is what the host does. The one test that needs a single id over two asset lists still names it, because that collision is what it is about. Every one of the 13 manifests in the golden corpus was already digest-correct; the replay suites pass unchanged. 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 |
||
|
|
b9643365ba |
fix(mobile): the Android audio engine forgets a stop issued while paused (#22132)
* fix(mobile): the Android audio engine forgets a stop issued while paused A JS toggleRecording(false) arriving while the activity was paused hit the value == isRecording early return, never reached stopRecording(), and left isRecordingBeforePause armed, so resumeRecordingAndPlayer() reopened the microphone with no JS owner (54 minutes on a Galaxy S24, OTA 0.0.51). Every stop now clears the resume flag and only the pause itself keeps it, via stopRecording(clearPauseResume), mirroring iOS's stopRecordingAndPlayer(clearInterruptionResume:); resume consumes the flag before acting on it. requestAudioFocus() also overwrote audioFocusRequest without abandoning the previous one, so every resume left a stale listener on the focus stack and tearDown() could only abandon the newest. Both paths now go through one abandonAudioFocus() owner. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the pre-Q permanent focus loss gives the focus back too (round 1) CodeRabbit on #22132: on API 21-28 the AUDIOFOCUS_LOSS branch stops recording and playback for good but kept the focus request, so an idle engine could hold focus after the other app released it. Q+ pauses and keeps focus to resume. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a resume never stops a recording it did not pause (round 2) pullfrog on #22132: with the equality guard gone, resume's toggleRecording(false) on a cleared flag stopped a recording JS started while the activity was paused, which a start straddling the permission activity does. Resume now only reopens. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
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 |
||
|
|
059ee59a48 |
chore(mobile): repin the recording corpus to main's tip after #22111 (#22118)
#22111 re-recorded the diff-notes goldens with baseline at its branch commit |
||
|
|
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> |
||
|
|
55378fce5b |
chore(mobile): repin the recording corpus to main's tip after #22072 (#22101)
#22072 re-recorded the speech.* goldens with baseline at its own branch commit |
||
|
|
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 |
||
|
|
6731f08c0b |
fix(mobile): hold every hybrid shell switch on a neutral state while the flag is unresolved (OTA phase C, ruling 33.7) (#22077)
* feat(mobile): give the hybrid shell switches a third answer for the unresolved flag Every route switch read the flag as `enabled !== true`, which spends the window before the read settles on the native screen. With the flag on that window costs a full native mount — subscriptions opened, screen painted — that the shell then tears down and replaces. `shellSwitchDecision` answers `pending` there instead, and `ShellSwitchPendingScreen` is what a switch paints while it waits: the base background with nothing on it, lifted out of the `web` route where this view already was rather than written again. A route the shell could never open is still answered `native` with no wait, because the flag cannot change that outcome and a neutral frame in front of a decided one is the flash this removes. No switch is wired to it yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold every hybrid shell switch on the neutral state while the flag is null All ten switches read the flag as `enabled !== true`, so the window before the storage read settles was spent on the native renderer. With the flag on that window costs a full native mount — the session screen opens its terminal, chat and tab subscriptions — which the shell then tears down and replaces, and the user sees the native screen flash before the page. Each now asks `useShellSwitchDecision` and paints `ShellSwitchPendingScreen` while the answer is `pending`, so exactly one renderer mounts and it mounts once. `tasks` and `agent-history` build their route before the decision rather than after it, because the decision needs to know whether the shell is a possible outcome at all. `web` already had this frame inline and now takes the shared one; its spinner's accessible name moves from "Checking host" to "Loading". The two cases that pinned the old behaviour — "renders the native panel while the flag read is still settling" on the files and agent-history routes — now assert that neither renderer mounts there. `shell-switch-null-flag.test.tsx` drives all nine switched routes through the three states and counts committed mounts rather than renders. Five route tests gain a `react-native` mock, which the neutral screen's `View` is the first thing in their graphs to need. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fence the neutral state across every hybrid shell switch Reading the flag through the shared decision is not on its own enough. A tenth switch could ask `useShellSwitchDecision`, ignore `pending` and fall through to its native screen, satisfying the reader rule and still flashing native in front of a flag-on user. So the census also says every switch names the neutral screen — existence, not shape; where it names it is the route tests' business. `matchesOf` is the snippet reader beside the three rules: the needles are identifiers, and a list of paths says which file moved but nothing about what in it did. Verified as a fence by deleting the `pending` branch from the tasks switch, which the rule caught and named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): measure what the neutral window costs a released phone The only thing this change costs a user with the flag off is the window itself, so it is worth a number rather than a claim. `loadMobileWebShellEnabled` answers `false` outside `__DEV__` before it looks at the key, so a release build reaches AsyncStorage zero times: the window is React's own passive-effect flush and one microtask, not a bridge round trip, and the switch has its answer on the first turn after the first commit. Pinned per switch, because a reader that grew a storage call would move it from a microtask to a bridge hop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the switch table so the tests-typecheck ratchet reads it `as const` on the table made the catch-all's `page` a readonly tuple, which `useLocalSearchParams`' own param type does not admit, and the file dropped out of `tsconfig.test.json`. An explicit element type says the same thing and checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep release builds out of the neutral state entirely Review round 1 (pullfrog). The hook started at `null` on every build, so a store build committed one neutral frame before its native renderer — the one cost this change landed on every released phone, and it bought nothing there, because the flag cannot be turned on outside `__DEV__`. `mobileWebShellFlagCanBeOn` names that build-kind test once, beside the reader that already made it, and the hook starts its state on the answer. Outside `__DEV__` the hook holds `false` from its first render, the `pending` branch is unreachable, and a store build commits native on frame one. The effect still runs and still answers `false`; the initialiser is a starting point, not a second read path. Red-first, both build kinds pinned rather than inherited from the runner: 18 of 54 cases failed — "commits native on its first frame" and "does the same when the bundler defined no `__DEV__` at all", nine switches each. The neutral screen is mocked with a mount counter now, because a frame committed and replaced inside one `act` leaves nothing in the tree; its shape stays pinned in the web route's test, which renders the real one. The census gains the build-kind fence as a third rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |