mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
7d4aede5d359eb16dfcff2a64503b95d6cc56ef7
22
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6c16914035 |
test(mobile): re-measure the web app's script sweep after agent.launch's capabilities moved out of protocol-version
The mobile web bundle check failed at 124 assets against a ceiling of 123. Main already sat exactly on that ceiling: its sweep table read 69 scripts at 16 routes while the tree builds 73, the whole margin of 4. This branch imports the agent.launch capabilities from their own module, so protocol-version is no longer pulled into the root layout and four other routes. That moves which routes share which modules, and the Qoder capability module, imported by protocol-version and the AI-vault resume path, no longer shares an importer set with anything, so it gets a chunk of its own: 74 scripts. The fence says to re-derive the bound rather than raise it, so the sweep is re-measured on this head (every prefix of the sorted route list). The worst route now adds 10 scripts (session), not 9, which moves the pinned shell crossing from 32 to 30 routes; main re-measured on its own lands on the same crossing. |
||
|
|
47cebbf5d2 |
ci: use ARM unit runners, overlap web builds, and reuse verifier fixtures (#23376)
* test: reuse isolated mobile bundle fixtures for verifier checks * ci: run PR unit shards on ARM and overlap independent web builds * docs: record controlled CI overlap and runner measurements * test: observe WebRTC packets with the host clock * ci: isolate Windows installer CIM probe from native test load * docs: record native probe scheduling validation --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
38bcdf76ac |
perf(ci): reduce queue pressure without paid runners (#23053)
* ci: measure complete unit file costs for shard balancing * perf(ci): reduce repeated PR setup and capture complete shard timings * perf(ci): seed reusable main-branch native and typecheck caches * fix(ci): stop superseded unit workflows from resisting cancellation * perf(ci): reuse bundle fixtures and share the baseline Git build * ci: record hosted gains and refresh main hook parity * ci: retain default workers after performance-budget regression * docs: record hosted mobile timing flake --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
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 |
||
|
|
63866c1e27 |
fix(mobile-web): page inputs lose the browser focus ring and hairlines draw one device pixel (OTA phase C follow-up) (#22569)
* fix(mobile-web): drop the UA focus ring from page text inputs Chromium rings every focused text field (:focus-visible); no native TextInput paints one. A zero-specificity rule in its own inline block beside the Expo root reset removes it for every page input; buttons keep the browser's ring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): draw page hairlines one device pixel thick react-native-web pins StyleSheet.hairlineWidth to 1 CSS px, three device pixels on a 480 dpi phone; native draws one. A build shim replaces that one assignment with React Native's own formula (roundToNearestPixel(0.4), else 1/ratio), so every page hairline matches native without touching components. A rendered check at a real device scale (Playwright's emulated scale floors borders to CSS px, which no phone does) measures both parity fixes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): apply the hairline shim to react-native-web's CommonJS build The page's dependencies require react-native, so esbuild resolves every importer to react-native-web's dist/cjs build, which the previous filter did not match: the shipped bundle still assigned hairlineWidth=1. The filter now matches both builds, the rendered check requires the package the way the page does, and a builder test reads every hairlineWidth assignment in the bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): draw page hairlines at a width WebKit paints too 1/ratio is exactly one device pixel, and WebKit floors that to 0 and paints nothing (0.3333px at a scale of 3), so the iOS shell would have lost every hairline. The shim now uses native's device-pixel count plus half a pixel; both engines floor a border to whole device pixels, so each paints what React Native paints at ratios 1, 2, 3, 3.5 and 4, measured per engine. The rendered parity check now runs in WebKit at a device scale of 3 as well as Chromium. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): check the parity style in the existing root-reset build Drops a second full build that read one HTML string, plus two assertions that tested the constant against itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): round the page hairline up to the 1/64 px layout step Half a pixel over native's count kept borders at one device pixel but made the separators drawn as `height: StyleSheet.hairlineWidth` straddle two rows at about half of all offsets. Both engines lay out in 1/64 CSS px, and WebKit stores an exact 1/3 as 21/64 and paints nothing, so the width is now native's device-pixel count over the ratio, rounded up to the next 1/64 (22/64 at 3). The rendered check adds a separator at a 10.1 px offset in Chromium and WebKit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
6ae5ef2d00 |
fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) (#22182)
* fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) `nodePaths` is a fallback esbuild consults only where normal resolution fails, so it never reached the four modules under `src/shared` that the page imports: sitting above `mobile/`, their bare `zod` resolved upward to the root's 4.5.4 while the 58 mobile modules beside them resolved to mobile's 4.4.3. Both shipped -- 808,470 bytes of duplicate source, and salvage combinators built by one instance nested inside schemas built by the other. Mobile's copy, because the mobile app already says so: `mobile/tsconfig .json` maps `zod` to `./node_modules/zod`, a shared module joins that program as an imported file, and `--traceResolution` shows tsc holding `zod-salvage.ts` to 4.4.3 today. The bundler was the only layer that disagreed with the app's own compile-time contract. The build drops from 67 scripts to 66 and from 8,055,568 bytes to 7,686,714, nearly all of it before the first route: the entry's static closure falls from 1,612,253 to 1,244,312. So the C7.8 sweep is re-measured rather than bumped, and the entry-budget note's static-import readings are re-measured with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-measure the pins the second Zod was inside The session route's module closure and the asset ceiling both counted the root's copy. Both re-measured rather than adjusted to fit. The closure falls 4363 -> 4269. The two module lists were diffed rather than the total inferred: 94 entries gone, every one of them `zod@4.5.4`, none added, because mobile's 79 were already in the closure, and the `local` count holds at 1021 -- this took no source module out of the page, only the second copy of a package. The asset ceiling is derived from the chunk envelope, which the re-measured sweep moved by one, so 30 routes now derive 248 assets and 31 derive 257. The crossing it exists to name is still 31. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): align three readings with the head that measures them All three described a bundle with two Zods in it, and the third was already stale on main. The escape-hatch note said 5 of the 14 routes break the entry budget on their own. Re-measured by making one route's manifest entry a static import and reading the entry's own static closure back, it is one: session at 3.32 MiB, with five more between 1.85 and 2.17 of the 3 MiB. Same readings as the note in verify-mobile-web-app-bundle.mjs, which is the thing this case asserts against. The mermaid pair said the bundle "emits 69 scripts". It emits 66, and this head's own fourteen-route prefix reads 64. The pair stays at 69 and 172: what the case pins is that the envelope tells the two apart, not either build's size. Saying so in the comment, with the warning that 69 now sits just under the envelope -- a sweep that falls further fails this on a frozen number, which is a signal to re-measure the pair rather than to raise the ceiling. The assets line said 215 against 112. 215 still derives from the pinned 172; 112 never matched the envelope it claimed, which allowed 114 on main and allows 113 here. The route count is the one number here that has to follow the tree, so it is spelled and pinned to the sweep's own length. The static-import readings are measurements rather than table counts and the mermaid pair must not move at all, so neither is spelled. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cut the mermaid and census comments to their claims Round 2 explained the frozen pair over four paragraphs and the census row over six lines. Both now say what they are for and stop: the pair, why neither number moves, and the one warning that matters. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
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 |
||
|
|
23207bfde2 |
feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) (#21957)
* refactor(mobile): move the review route body onto a component and the handoff seam (OTA phase C, C4.4) The review route file called `useMobileDiffReviewController` at its top level. A switch cannot keep it there: hooks are unconditional, so the whole controller — its client subscriptions included — would run behind the shell's page whenever the shell renders. As an element passed for `fallback` it is created and not mounted, which is how the explorer switch already behaves. `useRouter` becomes `useRouteHandoff` in the same move. It was the one raw expo-router router left in the review closure (measured: the only other value import of one is the seam's own web sibling), and inside the page the session screen `openSession` replaces to is native, so that target has to be handed back to the app rather than posted into a document that does not render it. The params are read in the component rather than handed down, so this is the route body and the route file above it is free to become a switch. `session-router-seam-census.test.ts` gains the module by name. Kept with `useRouter` the census reds twice — `imports nothing from expo-router that can navigate` names `MobileDiffReviewRouteScreen.tsx (useRouter)`, and the completeness case gains `useRouter` — which is what forces the swap rather than leaving it to a reviewer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): switch the source-control and review routes to the shell, still unregistered (OTA phase C, C4.4) Both take the files switch's shape: `firstParam`/`firstReviewParam` on every param, `shellScreenRoute` as the one predicate, `MobileWebShellScreen` keyed on `shellScreenRouteKey`, the native screen built as an element and passed for `fallback`. Both gain a `.web.tsx` sibling for `index.web.tsx`'s reason — the native file reaches OrcaMobileWebShellView, whose module throws at import in a browser, and the route manifest imports every route. Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it, which is the next commit; until then the flag is the only thing that changes and it is off. Query params are omitted rather than sent empty, and the whole record is omitted when none was named: `tab=` is a lens named nothing and lands on `changes` through a different branch than an absent one, and the same holds for `name`, `origin`, `scope`, `file` and `area`. `pr` and `history` are deliberately not switched. Both are `Redirect`s into `source-control`, and a redirect inside the page would leave the session bound to a pathname the page has left; left native they replace into this route and its switch mounts the shell. Three censuses red without their rows, measured on this tree: - `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` names the two new siblings; `states a reason for every override` reds on a placeholder under 20 characters. - `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no others` reds without the two `SWITCHED_ROUTES` names. - `shell-screen-route-census.test.ts` `walks the route tree and finds them` reds without the two switch names. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) Two entries in `MOBILE_WEB_PAGE_ROUTES`, five grants each, with the reason for each grant read off the screen that needs it. The two lists are equal on purpose: the hub's changed-file rows push review and review replaces back, and a target declaring no more than its opener is a hop the handoff keeps inside the document. Registering either alone would have put a native frame and a second bridge session between a changed-file row and its diff. `pageRouteGrants` is derived from this list, so the two rows are a consequence of the entries and there is no second table to edit. `pr` and `history` stay native redirects and are never listed; the derived target list at this tree is [files, files/preview, source-control, [p], accounts, agent-history, review, session, tasks, web], with no `pr` or `history` row, because the census reads call sites and both redirects name `source-control`. Measured on this tree, not carried from the draft: - The hop census goes 8 -> 16. The eight new rows are exactly `{/h/[hostId], agent-history, files/[worktreeId], files/preview} -> {source-control, review}`, each handed off for `native.clipboard.write` and the first four also for `externalLink`. `source-control <-> review` is absent in both directions, which a new case now asserts as grant-list equality rather than as the absence of a row — absent is also what an unregistered route looks like. - The Back census now walks six trees and finds 8 controls, both rules printing empty. The two new ones are `MobileSourceControlHeader.tsx:46 role=button label=Back to session` and `MobileDiffReviewHeader.tsx:48 role=button label=Back`, which is what C4.3 bought. The `ARRIVING_SCREENS` describe it wrote for this moment is removed: with the rows in `PAGE_SERVED_SCREENS` its trees are covered and its cases were a second reading of the same thing. - Both closures reach the haptics seam, so `haptics` is declared by measurement: the seam census derives the reaching set and its two cases pass with the routes in its `ROUTE_MODULES` map. Without the two manifest entries these red on this tree: `pins every hop the handoff must take away from the page`, `keeps the hub and review local to each other`, `declares only routes the bundle has a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`, and both haptics-seam cases. `build-mobile-web-app-bundle.test.mjs` is split rather than fenced. The two pinned entries put it at 607 non-comment lines against the 600 cap, and the declaration block is a different concern from how the bundle is built — it grows once per registered domain while that file does not. It moves whole into `mobile-web-page-routes.test.mjs`, named for the module it is written against, so the next route to register does not have to choose between a lint fence and a split it did not ask for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render-check the two page routes, and make the oversized stage-all readable (OTA phase C, C4.4) The render check mounts both routes in a real browser, asserts each paints its own screen rather than the Unmatched route with no console error and no page fault, and asserts each fetches its own chunk on a client-side navigation. It also reads the shipped `img-src 'self' data:` out of the Kotlin source it is served with, pins the Swift twin beside it, and asserts neither route leaves the origin or logs a policy violation while it paints. The avatar skip itself (ruling 3) is `PRCommentCard`: on web it renders its existing empty-avatar `View` rather than letting one `<Image>` per comment attempt a fetch the policy refuses. Its branch is pinned by a component test, which reds on the platform check being removed. The render check's off-origin case is honest about being the negative half only — no comment card renders there, because the PR chain behind it is not scripted, and the file says so. The `useAnimatedScrollHandler` risk is answered by the two static facts rather than by a probe, and they are recorded as assertions: the hook is deliberately outside the four `MAPPER_HOOKS` because it is an event handler, and its updater's only effect is a write to `scrollOffsetY`, which `RightDrawer.tsx` assigns in two places and reads in none. A later read reds that case the moment it is added. The `oversized` stage-all refusal (ruling 2, made testable by ruling 5) was a silent no-op, and this is the fix as well as the case. Measured on this tree before it: `git.bulkStage` with 12,000 paths posts one 1,033,012-byte frame, the shell's reader drops it with `{ kind: 'refused', refusal: 'oversized' }`, and the page's promise never settles — `busyAction` never cleared and `setActionError` was never called. Both new cases red by timing out at 15s against that path. Refused at the page's own send boundary instead, under the shell reader's own predicate rather than a second spelling of it: `isBridgeFrameWithinCap` is extracted from `parseBridgeMessage` and used by both sides. `sendFrame` answers `sent` / `oversized` / `port-failed`, so `sendRequest` rejects with a `BridgeRequestOversizedError` whose message is a sentence the panel puts on its error surface, and the members whose contract is a boolean keep it. No delivery-unknown mark: the frame never left, so nothing ran on the desktop and the smaller retry is safe to offer. The case runs the real chain — bridge port pair, `useMobileGitRequests`, `runGitWorkflow` — with only react-native and the haptics seam mocked, and asserts the message that lands is a sentence and that the busy flag is raised and then cleared. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the type assertions from the two new C4.4 test files (OTA phase C, C4.4) The changed-code quality gate named five, all in the files the previous commit added, and a fence is not the answer to any of them. A separate commit because a reported head does not move by amend. - The comment fixture is a real `PRComment` rather than a cast: the type's six required fields are all this case needs, and the SAFETY disable that stood in for them was inert anyway — oxfmt had wrapped it onto three lines, and a wrapped `oxlint-disable-next-line` matches nothing. - The image lookup goes through `findAll` on the host tag rather than `findAllByType`, which takes a component. Through `String`, because `node.type` is `ElementType` and React Native declares no intrinsic elements, so the compiler reads a bare tag comparison as unreachable. - The runners hook takes its router from `useRouteHandoff` with expo-router mocked under it, which is how a `RouteHandoff` is obtained rather than asserted into existence. No target is pressed. - The rejection and the diagnostic are read through narrowings instead of casts, which also drops an `expect.any` that only type-checked because of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a frame the page cannot serialize inside the send contract (OTA phase C, C4.4 round 1) Round 1 finding 1. The oversized refusal moved `JSON.stringify` outside `sendFrame`'s `try`, so a frame carrying a cycle, a `BigInt` or a throwing `toJSON` threw past the whole send path. Three things followed, all measured here on a cyclic `params`: - the caller was rejected with a bare `TypeError` from `JSON.stringify` instead of the `BridgeSendFailedError` every other undelivered frame raises; - no `send-failed` diagnostic was raised, so nothing recorded that a frame had been lost; - `sendRequest` opens the id before it posts and abandons it on the way out, and the throw skipped the abandon: 63 of the 64 in-flight slots were usable afterwards, against 64 on a client that sent no such frame. Sixty-four of them and every later request is refused with nothing to say why. `posted()` carried the same escape into the members whose contract is a boolean, where a throw is worse still: those callers are taps and teardowns with no catch on them. Serialization goes back inside the `try`, with the oversized refusal kept in front of the post. The docstring said the port arm's throw is never `JSON.stringify`'s, which was exactly the assumption that broke; it now says why the call sits where it does. The new file is the pin: the rejection's name, the diagnostic, nothing reaching the shell, and the slot count with a no-cyclic-frame control beside it so the count cannot pass by the cap moving. Both changed cases red on the serialization moving back out — `expected 'TypeError' to be 'BridgeSendFailedError'` and `expected 63 to be 64`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the page's frame cap to the reader's, at the boundary and by construction (OTA phase C, C4.4 round 1) Round 1 finding 2. Nothing held the sender's predicate to the reader's. Replacing `isBridgeFrameWithinCap(json)` with an inline `json.length > BRIDGE_MAX_MESSAGE_BYTES + 1` passed 85 of the 86 mobile-web-shell and source-control test files on this tree, and a frame at exactly cap+1 would then be posted and silently dropped — the hang the refusal exists to end, back for every frame in that one-unit band. Two rules, because either alone passes against the defect: - The boundary. A frame of exactly the cap is posted, arrives at `parseBridgeMessage` and is accepted; a frame one byte over is refused with `BridgeRequestOversizedError`, posts nothing, and is the same string the reader answers `oversized` to. An off-by-one reds the second. - The census. The client reaches the cap through the shared predicate and does not name `BRIDGE_MAX_MESSAGE_BYTES` at all, and the module that exports the predicate is the module that parses inbound frames. A private copy that is correct on the day it is written reds here. The overhead the boundary frames are built from is itself checked rather than trusted: a frame asked for at exactly the cap must serialize to exactly the cap, so the constant cannot rot behind an envelope that grew a field. Against the mutation both new rules red — `expected null to be 'BridgeRequestOversizedError'` and the census failing to find the predicate — while the rest of the suite stays green, which is the finding reproduced. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): count the outbound frame in the unit both shells count it in (OTA phase C, C4.4 round 1) Round 1 finding 3. Read off both shells rather than assumed, and they agree: iOS gates the inbound frame on `json.utf8.count` (`MobileWebShellView.swift`, through `MobileWebShellBridge.acceptsByteCount`) and Android on `json.toByteArray(Charsets.UTF_8).size` (`MobileWebShellView.kt`, through `acceptsMobileWebShellBridgeByteCount`), both against `640 * 1024`. UTF-8 bytes on each platform. The predicate was already right. `isBridgeFrameWithinCap` decides on `utf8ByteLength`, and the `raw.length` clause in front of it is a cheap refusal in the safe direction, not a second rule: every code unit encodes to at least one byte, so a string over the cap in units is over it in bytes too. The diagnostic was not. It reported `json.length` — UTF-16 code units — in a field named `bytes`, so a frame of CJK text read as a quarter of the cap at the moment it was refused by it. It now reports `utf8ByteLength(json)`, and the type says which unit that is. Pinned with a 250,000-character frame of three-byte characters, which is under the cap in code units and over it in bytes, plus a source case reading the measuring expression out of each shell. Three mutations, all red: dropping the byte clause from the predicate reds the refusal (`expected null to be 'BridgeRequestOversizedError'`) and the diagnostic; reporting `json.length` again reds the diagnostic alone (`expected 250094 to be greater than 655360`), which is the defect this commit fixes, in the number it would have printed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): drop the render check's avatar assertion, which could not fail (OTA phase C, C4.4 round 1) Round 1 finding 4. The case asserted that no avatar host was requested while both routes painted, which reads as a proof of the web skip and is not one: no comment card renders on either page, because the PR chain the file's own closing note names is not scripted. Reproduced here — deleting the `Platform.OS !== 'web'` guard from `PRCommentCard` leaves the file at 5 passed. Deleted rather than propped up. Giving the page a presence precondition means five hand-written fixtures against five Zod schemas inside the shell double, which is exactly what the harness's docstring says that double must not become. So the only proof of that branch is `pr-comment-card-web-avatar.test.tsx`, which reds when the check is removed, and the render check now says so in its header instead of implying otherwise. What survives is a property of these two closures rather than of that component: not one request leaves the origin while either route paints, and nothing either paints violates the policy. That one can fail — planting a `fetch` to a provider host in a module both routes reach reds it twice, on the console-error case and on the off-origin case, with the `connect-src 'self'` refusal in the output. The CSP half is unchanged and was never in question: the served header is read from the Kotlin source and the Swift twin is pinned beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e9b180685b |
feat(mobile): render Mermaid diagrams on the page from one deferred engine artifact (OTA phase C, C7.10 B) (#21871)
* test(mobile): measure mermaid rendered in the page Red-first for C7.10 item B. The check mounts the real web sibling in chromium and webkit under the shipped shell CSP and asks four things of it: that a diagram renders with zero policy violations and zero eval / new Function calls, that the SVG is the native buildHtml's own output once the diagram id and xmlns:xlink are normalised away, that a hostile diagram lands inert, and that a source change, an unmount and a remount leave exactly one SVG and no listener of the first mount. The equality oracle is buildHtml itself, bundled for Node behind a Proxy stub for its native imports and served as its own document in the same browser, so neither side of the comparison is retyped. All eight cases fail on this commit: the sibling is still the labelled source box. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fence the session download rather than its module list Ruling 28. mobileWebAppRouteClosure reads metafile.inputs, which holds dynamically imported modules under splitting: true exactly as it does under splitting: false, so it cannot say "on demand" about anything: an on-demand mermaid moves the session route's module list 4320 -> 6362 while its download does not move at all. So the fence moves to entryStaticClosure. The new helper walks the emitted chunks from the output the route's own module landed in and follows import-statement edges only, and hands back both halves, because mermaid's absence from the download is only a measurement while its 66 files are present in the deferred half. The module list's new total is recorded in the docstring with its reason and asserted beside the engine's own file count, which moves only when the pinned mermaid version does. Red on this commit: no mermaid in the closure yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render mermaid in the page The web sibling stops being a source box. mermaid is a browser library, so the page imports it inside the render effect and draws the diagram in this document: no WebView, no 3.7 MB engine string, and nothing of the engine downloaded by a session with no diagram on it. What replaces the sandbox is mermaid's own securityLevel: 'strict', which runs its serialized SVG through DOMPurify. The native path's </script> escaping has no analogue here and needs none, because the source is a JS string argument rather than text spliced into an inline script. Measured in both engines: a script in a label, a </script>, an onerror and a javascript: click all land inert. The configuration is now one object both hosts read, so the theme cannot drift between the page and the phone; buildHtml serializes it instead of holding a second copy. It gains suppressErrorRendering, because mermaid otherwise draws its own error diagram into a temporary element and leaves that element behind when it rethrows -- an orphan SVG on the page, and on native a diagram the component is about to replace with the source box anyway. The dispose clears the host on unmount and on a source change; the id is a useId, because mermaid writes it into the stylesheet inside the SVG and it has to be a CSS identifier. Also re-records the closure total the previous commit pinned: with the real component the session route's module list is 6376, not the design probe's 6362, and the reason is in that file's docstring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): budget the deferred engine's chunks apart from the routes Putting mermaid on the page took the app bundle from 69 emitted scripts to 172, and the asset budget failed: 215 assets against a ceiling of 115. The cause is not a page split running away, which is what that ceiling is for -- it is that mermaid lazily imports each of its own diagram types, so one import() lands 103 scripts no route count predicts. So the ceiling gains a second term, named and measured (172 scripts with mermaid against 69 with it aliased to a stub, at 11.17.2), rather than the route term being raised to cover it. A page split running away still fails on the route term, and the failure still says which of the two grew. The consequence is worth reading twice: the derived ceiling has to stay inside the 256 assets the shell will load, and with 42 images it now crosses that at 24 routes instead of 50. The bundle is at 215 today with 14 routes, so there is room for about ten more routes before a green build produces a manifest no phone will open. Measured by the config/scripts suite failing on this head, not predicted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): pre-bundle the page's mermaid into one artifact import('mermaid') from inside the app bundle emitted 103 scripts, not one: mermaid lazily imports each of its own diagram types and esbuild splits along those boundaries. Every one of those scripts sits inside the OTA generation the phone has already downloaded, so the split moved no bytes over the wire and spent 103 of the 256 manifest assets the shell will load -- which is the scarce resource here, and the reason the previous commit had to invent a second ceiling term. So a sibling generator bundles the package into one ESM module beside the WebView engine it already builds, emitted by the same postinstall run, gitignored and lint-ignored with the others. The page imports that artifact on demand instead, through a loader whose return type names the two calls the component makes -- checked against the artifact's own inferred export rather than cast to it. Measured, at 14 routes: emitted scripts 172 -> 69 (68 with no deferred engine at all) manifest assets 215 -> 112 (111 with none) session modules 6376 -> 4323 (+3 over main: config, loader, artifact) chunks fetched for one graph TD 27 -> 1 bytes fetched 837,530 -> 3,482,965 The static-closure fence is unchanged in meaning and now reads on the artifact: absent from every chunk the route reaches by an import statement, present in the deferred half. The rendered SVG is byte-for- byte what it was, so the equality against the native document still holds on both engines. Also adds the diagram to the webview-consumers list, which is what that list means: its native component imports the package and its sibling is what the builder resolves instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * revert(mobile): drop the deferred-engine ceiling term, keep the control With the engine pre-bundled into one artifact the bundle emits 69 scripts at 14 routes against the route term's 72, so the second term this series added has nothing left to do and the route count is the only term again. mobileWebAppBundleMaxChunks and the asset ceiling derived from it are back to what main has; the shell's 256 assets are crossed at 50 routes again rather than at 24. What stays is why. A ceiling raised to admit 172 scripts would have admitted any split at all, so the budget test gains the control that holds the line: the single-artifact count passes the ceiling and the lazily-chunked count fails it, both measured at 14 routes, with mermaid named as what produced the second. Red before the term came out: the control failed asserting 172 > 175. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep build output out of the raw-request-port census The census walks mobile/src for AST reaches into the unvalidated request port, and the pre-bundled mermaid artifact is the first generated file under src that is executable code rather than a string literal. Two of its own vendored dependencies contain the token `sendRequest`, so the walk read minified third-party code as a new call site and asked for an inventory line nobody can ever migrate. So `*.generated.ts` joins node_modules and test files in that file's stated list of what it does not scan, with the reason. The scripts that emit those artifacts are ordinary source and are still scanned, which is where a real reach would be. Two halves to the new control, because a filter that skipped everything would satisfy either alone: nothing generated is left in the scan, and the matcher still finds the port when handed one line of code. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): escape the shared config into the native inline script buildHtml spliced JSON.stringify(MERMAID_DIAGRAM_CONFIG) straight into the inline <script>, twenty lines below the function that exists because JSON.stringify leaves `<`, `>`, `&` and the U+2028/9 separators raw. Inert at today's five hex colours, and not inert for a themeCSS or a font stack, which is free text going into the same script element. So the escaping splits from the stringify and both callers use it: the source keeps its own wrapper, the config gets one. Those characters only ever appear inside JSON string literals, so escaping them is valid for an object serialization exactly as it is for a string. Red first: a config carrying `</script><script>` put four raw closers in the document where a benign build has two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the page mermaid type against the package's own The loader returned the artifact's default as PageMermaid, which checked that two names exist and nothing about their shapes: the artifact is minified vendor output and both members infer as `any` there -- a probe assigning engine.render to a number compiles -- and `any` satisfies every signature there is. So the shapes are asserted against the package's `Mermaid`, which is precise. A PageMermaid member whose signature the engine does not really have now fails at this line rather than at a call the page makes. In the product module, not a test: mobile/tsconfig.json excludes test files, so a type-only assertion in one is never compiled. Underscored because it is a compile-time statement with no runtime reader, which is the form the linter asks for. Control, verified both ways: changing render to (id: number) => Promise<{ svg: number }> reds tsc naming both parameter and return, and the real signatures compile. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): re-measure the chunk series and say what it does not show The four-point series was stale and read as a slope it is not. Measured again on this head, by copying the route tree and dropping routes from the end of the sorted key list -- both siblings of each, because deleting a .web.tsx alone leaves the native file for the builder to resolve and measures an entirely different closure, which is how the first attempt at this produced 77 scripts for 14 routes: 8 routes -> 32 scripts 10 routes -> 43 12 routes -> 61 14 routes -> 69 (the real tree) Between four and nine more per route depending on which route, so 4r + 16 is a bound and not a fit, and the justification now says that instead of claiming three per route. It also says the part that matters more: at 14 routes the tree measures 69 against 72, and the last two routes cost the 8 the ceiling grants for two. The fence is at break-even, and the new assertion states that slope from the function rather than from a comment. Also records what the generation weighs, since every chunk ships in it whether or not a phone fetches one: 8,016,714 bytes across 112 assets against the 9 MiB ceiling, 84.9%, 1,420,470 left. It was 4,539,090 before item B, and the engine is the difference. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the native fallback under suppressErrorRendering The shared config reaches the phone too, and it gained a key the native path did not have. So the native document is now loaded for a diagram that throws, in both engines, with window.ReactNativeWebView standing in for the host: mermaid's run still rethrows, the document's own catch still posts `error`, and that is the message the component turns into the source box. Measured both ways, so the case says which half the key owns. Whether the fallback fires does not depend on it -- `error` is posted with the key and without it. What depends on it is that nothing is drawn behind the fallback: removing the key leaves mermaid's own error diagram in the document and reds this case at 1 SVG against 0, on chromium and webkit alike. The control is the same document for a diagram that parses: a height, not `error`, and one SVG. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one walk for every source census, without build output Nine censuses under mobile/src each held a copy of the same recursive walk, and each decided for itself what a source file is: seven had no opinion about generated files, one excluded them in its own regex, and one had the exclusion I added last round. So all nine read 7.9 MB of emitted vendor code -- 3.7 MB of mermaid for the WebView, 3.5 MB of it for the page -- and the two largest censuses TypeScript-parsed all of it, looking for call sites nobody wrote and nobody can move. That is what took rpc-params-contract-type-only-boundary over its 5 s timeout in CI once the fifth artifact arrived. Measured here, median of 3, import plus tests: main, 4 artifacts, no exclusion 1004 ms (slowest case 831 ms) with the 5th, no exclusion 1513 ms (slowest case 1358 ms) with the 5th, this commit 947 ms (slowest case 788 ms) So it lands below where main has it, not merely below where I left it. Across the nine, four more halve: rpc-operation-cast-fence 769 -> 441, rpc-subscription-boundary 946 -> 468, unchecked-rpc-reader-boundary 1042 -> 538, lifecycle-owner 747 -> 433, reanimated-web-mapper-deps 1028 -> 516. The two that already excluded generated files do not move. What each census counts as interesting -- extensions, whether test files are in -- stays its own, because they genuinely disagree. What counts as a source file at all is now said once. The control is the file that started it: a *.generated.ts whose text holds exactly the import a census is hunting, planted beside an ordinary file carrying the same text. The generated one is not returned and the ordinary one is, so the absence is a measurement. A second control reads mobile/.gitignore and holds the predicate to every artifact the tree generates, and a third fences the walk itself to one spelling, so a tenth census cannot paste the cost back in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct why the type pin sits in the product module The comment said a type-only pin in a test file "is never compiled". That is false: mobile/tsconfig.json excludes *.test.ts, but tsconfig.test.json is a second program that does check them, run by check:tests-typecheck and held by the tests-typecheck ratchet. The conclusion is unchanged and the reason is now the true one. The app's own typecheck is the unconditional gate and would not cover a pin written in a test; the test program is real but carries a grandfathered baseline and a few files held outside it on purpose. And the assertion is about this module's own type either way, so it belongs beside it. Comment only; tsc, the ratchet and both lints re-run on the file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
c77a82f783 |
feat(mobile): fire the page's haptics over the bridge notify (OTA phase C, C7.10 E) (#21864)
* feat(mobile): give the page a haptics notify and the grant that gates it `native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly the five `src/platform/haptics.ts` has, and the single token `haptics` joins `BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb because nothing is owed back: a reply would spend a slot in the same 64-deep in-flight window a forwarded request does, and there are 90 call sites in this app, some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30). The arm's fields live in their own module because `bridge-envelope.ts` is at its line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays in the envelope, so the fields are spread in beside it rather than reading it back through an import cycle. The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other device-local notify does: the host is the protocol's side of the bridge and a static import of the app's haptics would put `react-native` and `expo-haptics` in its graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping — `haptics.ts`'s own functions, its `Platform.OS` split and its Android `HapticFeedbackConstants` untouched. The dispatch branch rides along with the union rather than waiting for the page side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over that union, so the shell does not compile without it. That is the totality working, and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is. Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin and the missing-row type error all failed against the tree before this. Control on the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): post the page's haptics over the notify instead of doing nothing `haptics.web.ts` stops being five no-ops. Each of the five posts its own kind through the notify seam the entry publishes — the same shape `publishExternalLinkOpener` has, and for the same reason: every caller is a plain function inside a row's press handler that no provider wraps. `notifyHaptics` joins the page client beside the other gated notifies and answers whether the frame left, which nothing reads: a tap that did not buzz is what the page did before this, and a warning per refusal would be one per row of a scrolling list. Measured off the frame the client posted rather than a written copy of its shape, which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection / success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`, and a twelve-row scroll 888 bytes across twelve frames. The `web-overrides.json` reason now says what the file does instead of what it declines to do. Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a function`, and the six client cases on `notifyHaptics is not a function`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): grant haptics on every page route, with a census that derives the list All five declared page routes carry the `haptics` grant, and the list is a measurement rather than a hand choice: `WorktreeListRow` is in every page closure and calls the seam, so a route without the grant is a page whose taps stop buzzing with nothing on screen to say why. Grants are resolved once from the route the shell opened and held for the session, so the declaration is the only place to fix it. `mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one: it reads the kinds off the tuple that declares them, finds every exported `trigger…` function in a haptics module, and reports the kind each one posts. The posting call is found through the binding `publishHapticsNotifier` assigns rather than a local spelled `post`, because a rename would otherwise turn every posting site into a non-posting one and leave this green on a page with no haptics at all. The census proper holds each route's closure to the `.web.ts` sibling, asserts at least one importer so the grant is not idle, and derives the granted-route list from the closures. The control is the design's: the same walk over the native sibling finds the same five functions and no posting site, so "all five post" is a number rather than an empty scan. Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post reds 1 of 23. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record what the haptics notify costs a page closure One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it arrives through `page-route-policy.ts` reading the grant token rather than through the seam, whose import of the kind type is erased; its only dependency is `zod`, which the envelope already put in every closure, so the module total moved by the same one. Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336. Pinned structurally rather than as a total, because an absolute closure count is main's to move and a number that drifts for unrelated reasons is one nobody reads. The call sites this replaces, measured over product modules: `triggerError` 43, `triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10, `triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus `page-haptics.ts` itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): carry the haptics grant into the shell's two grant pins `bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over and reaching the real module pulls in an Expo runtime this test does not have, which failed the whole suite at import. Which expo member each kind reaches stays in `page-haptics.test.ts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): map each haptic kind to a named import, not a namespace index The changed-code gate refuses a computed reference into an imported namespace, in both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()` is a call nothing can follow. Each function is a named import instead, which also keeps the second compile-time direction — a row naming something `haptics.ts` does not export is now an import error rather than a `keyof` mismatch. The third direction moves with it, from a namespace read in the test to the census that already reads both files' text: `hapticsImportedNames` names what the shell's mapping takes from the app's haptics, and the census holds that to the five the native file exports. So a haptic added there with no kind of its own still fails, and now it fails where the other two siblings' names are already compared. The test's two `as` assertions become one annotated hoisted type, the shape `MobileWebShellScreen.test.tsx` uses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the true reason the haptics grant is one token `GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>` with lowercase segments, which is why it rejected `native.media.readChunk` and rejects `navigate-back`, not a dotted name as such. So four comments claiming a route declaring the notify's own name would have its bundle refused were false, and they are gone: the grant is a token because the notify table's grants are tokens, a notify not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from the verb table alone. Also folded, with the false claim: `implementedPageRoutes` filters on `grants.every(implementsGrant)`, so a token every page route declares couples the whole set to a shell that carries it — against one without it, no page route is served at all and the phone renders five native screens. Stated in the function's docstring and beside the census's derived list, and pinned: the same declaration under a grant this build does not implement comes back empty, with the token-free route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS` reds that case. `%#` consumes no argument, so the web seam's five cases were titled with the whole function body; the kind is the first element now and `%s` names it. One 110-char comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two still over it there are main's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e6aa90ff36 |
test(mobile): certify the browser pane's golden families and render it in a page (OTA phase C, C6.5) (#21777)
* test(mobile): pin the browser pane's golden families The half pin for C6: 4 families, 15 goldens, every verdict the one C2's rule predicts. Measured per family with vitest `-t` over the full 787-golden corpus, with C1's 103 reproduced golden-for-golden as the control: 6 byte-identical, 9 result-absent-settlement. No composed `c6-page-closure.ts`: a composed table is pinned against a route and the browser is a pane, so C7's route is what composes this with C1's. The derivation census does not wait for that route. `mobileWebAppRoute- Closure` becomes one case of `mobileWebAppModuleClosure`, which takes any entries, so the pane's own closure can be read from the module. Two cases: the pane alone reaches exactly the pinned four, and the pane beside `app/h/_layout` adds exactly those four and no other, with the layout reproducing C1's 22 as the control for the difference. Closure at this base: 48 local modules alone, 34 beyond the layout, 30 under `src/browser` and four through the web siblings. The design said 23, all under `src/browser`; it was measured before C6.2 and C6.3 added those siblings, so the pin carries the re-measured number. `browser.screencast` has no golden at all, so this certifies the input path and says nothing about the frame path. Red first: with `browser.wheel` dropped from the table, both census cases fail naming the missing family; restored, the file's 10 cases pass and the parity suite reports "15 goldens in 4 families, 6 byte-identical". Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the frame budget against the shell's real frame Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's device scale from a skeleton it builds itself, and until now its only check was another skeleton of the same shape in the same file: two copies of one assumption agreeing with each other. This measures the real thing. A frame with CDP's nine metadata fields and a real `Page.screencastFrame` timestamp, encoded by C6.1's `encodeBridgeScreencastFrame` and serialized by the real `BridgeHostSubscriptions`, posted through the host harness: 303 bytes besides the image, against a bound of 516. Held above is not enough on its own — 213 bytes of slack is room for the shell to grow the envelope by a field the page never hears about — so the bound is reconstructed exactly instead. Every byte of that slack is a number this frame prints narrower than a double can; adding those back gives 516 on the nose. The budget cases run a generated noise image at the budgeted scale, not a committed fixture: the worst case is the image JPEG compresses least, and a photograph sits a tenth of the way to it. 901,161 px at 0.545 bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of the 655,360-byte cap. One envelope more and the shell drops it, which is ruling 1 read from the budget's side. Red first, two ways. Drop the metadata widening from the bound and three cases fail, the sharpest being the real shell answering the frame the page thought it could send with zero posts. Add a field to the shell's own envelope and the reconstruction fails at 516 against 548, where the existing suite stays green on all 14 — which is the drift this file exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the measured frame bytes, correcting |
||
|
|
ac024d4f05 |
feat(mobile): serve the files explorer and preview from the page (OTA phase C, C3.1) (#21710)
* refactor(mobile): take the files screens' router from the handoff seam Inside the shell's page a screen is one document standing in for one screen, so a target the page does not render has to be handed back to the app that does. `useRouteHandoff` is where that decision lives, and its web sibling is the only thing that makes it; both files screens held expo-router's own `useRouter`, so on the web the explorer's Back and the preview's Back would post nothing and a target outside the page would paint Unmatched over the page it is on. Natively this is the same object — `route-handoff.ts` is `useRouter()` — so no behaviour moves here, and `back()` stays expo-router's until the navigate-back verb lands and the seam starts wrapping it. A census rather than a behaviour test: neither screen's own tests can see the difference, because a push that is never handed off still works for a target inside the page. It walks this directory, refuses a value import of expo-router, and names the two screens that must hold a router so a walk that found nothing fails instead of passing empty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the shell stand in for the two files routes Both route files take the index.tsx shape — flag, MobileWebShellScreen, native screen as fallback — and both gain the `.web.tsx` sibling that shape forces. Inert until the manifest lists these routes: the shell answers `native-route` for a route the bundle does not name, which is what `fallback` renders, and the flag is `__DEV__`-only besides. Listing them waits on C2.3 and C2.5. The sibling is not a precaution. The manifest defers every route behind `import()`, so a native-only route module is invisible until the page opens that route; the render check now opens both and, without the siblings, painted `expo-modules-core.requireNativeViewManager is not available on web` instead of the screen. That is also why the two cases render the route rather than asserting a file exists. The file path never becomes a path segment: only `hostId` and `worktreeId` are spelled into the pathname, encoded, and everything else — `relativePath`, `absolutePath`, `cwd`, `pathText` — is a param, which is how a `/`, a space or a `..` stays out of the segment vocabulary the bridge holds a route to. The preview render case proves the round trip on `docs/my notes/readme.md`. `mobileFilePreviewShellParams` drops a param the normalizer left `undefined` rather than sending it empty, because the page reads these back through useLocalSearchParams where `line: ''` and no `line` are different screens. Its test drives the normalizer rather than a hand-written literal: the literal omits the key entirely, so it held with the filter removed. The preview case also records what React Native Web says out loud — BackHandler is inert on web, so Android back inside the page skips the unsaved-draft prompt. Named in the assertion rather than filtered out, so closing it is a change to that line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ask about an unsaved draft in the screen, not through Alert React Native Web's `Alert` is `static alert() {}`. Inside the shell's page that made Back with an unsaved terminal-artifact draft a button that did nothing at all: no prompt, because the dialog is a no-op, and no navigation either, because the code took the branch that shows one. Silently, with nothing on the console. The prompt is now a row under the header. Not `ConfirmModal`, which every other confirm here uses: that is a `BottomDrawer`, and C1.9 has Reanimated's animated styles never reaching the DOM node on WKWebView, so on iOS in the page the drawer parks off-screen and Back would be dead a second way. This paints the same on every platform with no animation behind it. Hardware back is registered natively only. React Native Web's `BackHandler.addEventListener` logs "BackHandler is not supported on web and should not be used." and hands back an inert subscription, so the guard never armed there regardless; the render check asserted that console error on main and now asserts none. The degradation is real and stated rather than hidden: Android back inside the page pops the native stack without asking, and the page's own Back control is where the question lives. The decision moved to a hook so it is testable without a screen: the prompt also drops itself when the draft it was about is saved or reverted, which is a state `Alert` had no way to be in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep expo-haptics' DOM shim out of the page expo-haptics has a web build, and with no `navigator.vibrate` — iOS Safari, which is the WebView the page runs in — it fakes a haptic by appending a hidden `<label><input type="checkbox" switch>` to `document.head`, clicking it, and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to exactly that stray click, and the file explorer calls `triggerSelection` on every row tap, so C3 is the first domain to fire it per tap rather than per long press. `haptics.web.ts` answers the same five names with nothing. A phone holding the page is a phone whose native app is right there with the real haptics, and a missing tap feedback is worth less than a tap that does not register. The test reads the shipped bytes rather than the import, because that is the claim: with the override removed the bundle carries `ariaHidden` and `pointer: coarse`; with it, neither, nor the `setAttribute("switch"` that does the clicking. Not `navigator.vibrate` — react-native-web's own Vibration export calls that and touches no DOM until something invokes it, which cost this test one wrong red before it was narrowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the files routes native when the page could not be given one A file path is a param, so `/`, spaces and `..` all cross safely — but `BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 and a Windows long path is not bounded by anything the user cannot exceed. The symptom is not the blank document the design predicted, and the correction matters: `bridge-host.ts` already parses the route against the page's own schema and drops it to `null` when it fails, so `init` arrives naming no screen and the page paints "Update Orca to open this workspace" — a wrong message about a fine app, over a native screen that works. Deciding before the switch instead leaves the route native, which is where every route starts. The schema is the predicate rather than a copy of its bounds, so the rule cannot drift from the half that matters, which is the half the page reads. The same call also refuses a `worktreeId` the segment rule will not route: `..` survives `encodeURIComponent`, which is the C1.8 class. The tests assert the schema really refuses each input before asserting the guard does, so neither case can pass by being impossible. This belongs in the shell beside the schema; it is in the files domain while the contract files are the C2 lane's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin what keeps a file path out of the route vocabulary Seven shapes, one case each rather than a representative: a plain path, a space, a dot segment, an already-encoded slash, a fragment, non-ASCII, and an absolute path. Each is checked in the two directions a path travels — the href the shell writes into the page's history, and the href the page would hand back — for both the pattern accepting it and the path coming back out of the query unchanged. The counterfactual is in the file: the same paths spelled as a segment are refused. Without that, the cases above would hold for a rule that was never doing any work. Mutating `stringifyRouteHref` to join its query by hand instead of through `URLSearchParams` fails three of them. Also fixes two new test files the tests-typecheck ratchet caught: the partial `react-native` mock needs a typed `addEventListener`, `act` will not take a callback that returns a value, and `findAllByType('Pressable')` does not typecheck against `ElementType` — the neighbouring files that do it are grandfathered, so the tag comparison goes through a helper instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): derive the discard prompt instead of clearing it in an effect Both changed-code gate findings, which the lane had not run until the last commit. React Doctor is right: the effect that cleared the prompt when the draft went away adjusted state after a prop changed, so a save landing while the prompt was up painted one frame still offering to discard nothing. The prompt is now `asking && hasUnsavedDraft`, which cannot be stale by construction, and the test that covers it passes unchanged. The hoisted mock's `as` on a string literal is gone too: the literal narrows on its own and the tests reassign it, so the holder is annotated instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): add the files routes to the hybrid shell flag census The census pins every file that reads `useMobileWebShellEnabled`, because a reader nobody listed is how a dark feature stops being dark. C3's two routes are deliberate entries: each has a native screen behind it as `fallback`, and each is inert until the manifest lists the route. Found by the full mobile suite rather than by the files subset this lane had been running per commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve the files explorer and preview from the page The last C3 commit: both routes join MOBILE_WEB_PAGE_ROUTES, and the shell starts rendering the page for them on a phone with the dev flag on. Grants are not the same for the two, and the difference is the point. Both take `navigate` (Back pops the native stack, and the explorer's rows open the preview beside it) and `storage` (the shared components the host layout renders above them). Only the preview takes `externalLink`: a Markdown preview renders links and `MobileMarkdown` opens them through the platform seam. The explorer does not, and measuring is what says so rather than reading. Every page route reaches `external-link.web.ts` — `/h/[hostId]` and agent-history included, both granted nothing for it — because the protocol wall in the shared host layout imports it. So closure membership is not the oracle for a grant; the question is whether the route's own screens call it, and only the preview's do. `MobileMarkdown` is in the preview closure and absent from the explorer's, which the census now asserts in both directions. Neither route writes a clipboard, so neither takes `native.clipboard.write`; the census pins that as the absence of both `ExpoClipboard.web.js` and the clipboard seam, with the tasks closure as the control that the probe can see one when there is one. The seam predicate moved into a module both censuses import rather than being restated per series: two spellings of one rule drift, and this one is a regex. Red-first: both manifest assertions failed on the new entries before they were updated, and routing `MobileMarkdown` around the seam fails the preview's census while leaving the explorer's passing, which is the asymmetry the grants encode. Closure sizes as the page ships them, extensionless so the `.web.tsx` is what is measured: explorer 3439 modules / 302 local / 10 under src/files, preview 3667 / 331 / 20. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the files route's ids as one value and key the shell on them Two round-1 findings, both reproduced before the fix. A repeated query key reaches `useLocalSearchParams` as an array, and the explorer read `hostId` and `worktreeId` bare. `String(['a','b'])` is `a,b`, so the template built `/h/host-a%2Chost-b/files/wt-1%2Cwt-2` — a single segment the bridge's rule accepts, and the shell would open a page for a host nobody has. Read through `firstParam` now, as the tasks and agent-history switches do. The preview already went through `singleParam` and is unchanged. Neither switch keyed `MobileWebShellScreen`, where `index.tsx`, `tasks.tsx` and agent-history all do. A host captures the grants its session opened with, so a screen reused across a route change keeps authorising frames under the grants of the route the page has left; only a remount drops that bridge. Both are keyed on the route pathname now, with agent-history's reason. The new route test is the agent-history one's shape. It caught both: the array case landed on no route at all, because `name` was an array too and the schema refuses a non-string param value, and the two lifecycle cases saw a prop update where a remount was owed. It also needs agent-history's `lucide-react-native` mock, since `firstParam` lives in the source-control barrel. `name` is now omitted when empty rather than sent as `name=`, matching the two switches beside it: an absent label lets the panel derive its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): confirm a discarded draft with the app's own modal Round-1 findings 3, 4, 5 and the minor one. **ConfirmModal, not the bespoke row.** The row existed because C1.9 had Reanimated's animated styles never reaching the DOM node on WKWebView, which left every BottomDrawer parked off-screen. C1.10 (`b7c06900e2`, an ancestor of this branch) fixed that with a dependency array on the mapper hooks, and the drawer render check now holds it on WebKit as well as Chromium. With the reason gone the row does not stand on its other merits: `Alert.alert` was modal on native before the page existed, and the row quietly changed that for phones too, so the app's own confirm is both the idiom and the closer behaviour. `MobileFilePreviewDiscardPrompt`, its test and its thirty style keys are gone; the hook's state machine and its tests are unchanged. **The encoding test claimed more than it pinned.** Hand-joining the query reds only three of the seven shapes; `docs/readme.md`, `../etc/passwd`, `docs/日本語.md` and `/logs/run.txt` are encoding-neutral in the query, whose pattern half is `[^#\s]*` and admits a slash, a dot segment and non-ASCII verbatim. Rather than narrow the claim in a comment, the split is now pinned by behaviour: each neutral shape must survive the query unencoded, each load-bearing one must not. Moving `docs/readme.md` between the lists fails it. **The manifest comment named one shared-layout opener and there are two.** The New Workspace source field, which the sidebar renders on a wide layout, opens a URL through the seam as well. Both are the shared layout's and every `/h` route reaches both, `/h/[hostId]` included with no `externalLink`, so the tablet tap is dead on all of them — recorded here as pre-existing rather than fixed, since the grants do not move. **Minor:** the dot-segment case in the guard test now asserts the schema refuses the route before asserting the guard returns null, as the length case does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop every page drawer logging a BackHandler error when it opens Round-2 findings. **The registration belongs to the drawer, and that is where the guard went.** `mounted-bottom-drawer.tsx` armed `hardwareBackPress` whenever a drawer was visible and interactive, with no platform check, so the hook's claim to have dropped that console line held only while its prompt was closed — and every page drawer since C1 has logged it on open. Platform-gated at the drawer now; the hook's comment says so rather than claiming the credit. **Nothing had ever opened a modal in a browser.** The render check next door mounts both files routes and reads what they paint but taps nothing, so `ConfirmModal` inside the page — a BottomDrawer, so Reanimated, a portal and a gesture handler — was unproved. A new render file loads an editable terminal artifact through the harness's scripted reply, edits it, taps the page's Back, and asserts the prompt's title is up and no BackHandler line is on the console. Red first on exactly that line; the prompt itself painted, which is also the first proof on a browser that C1.10's fix carries a real drawer in the page. A second case answers Stay and checks the draft survives. Its own file rather than the render check's, which is at 482 of the 600-line cap; registered in pr.yml. **The encoding rule was stated wrong.** Two rules decide it and neither is about paths: the pattern's query half refuses whitespace and `#`, and `URLSearchParams` is form-urlencoded, so it reinterprets `&`, `+` and a valid `%XX`. `a+b.ts` reads back `a b.ts` and `a&b.ts` reads back `a`, so both are load-bearing; `a=b.ts` and `a%b.ts` are not, because only the first `=` splits the pair and a lone `%` begins no escape. A newline joins the load-bearing list as the refused shape rather than the altered one. **The web sibling read its params bare** where the native one uses `firstParam`. Not reachable — the page only arrives through `init.route`, whose params are already `Record<string, string>` — but the two files are meant to be one screen. The preview keys on the pathname alone, and the comment now says why that is enough: every caller in this tree pushes. Closures after this: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. The explorer grew two modules because its web sibling now reaches `firstParam`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the explorer the grants the preview needs, and key on the route Bot findings, one of them a real gap. **Pullfrog is right, and my grant oracle was half a rule.** Grants resolve once, from the route the shell opened: `grantsForRoute` reads `session.routePathname` and `init.grants.native` carries the answer for that session. The explorer's rows push to the preview, and because the preview is a page route that push stays inside the same document — no second `init`. So a preview opened that way runs under the explorer's grants, and a Markdown link in it was refused by `notifyExternalLink` with nothing on screen to say why. "Does the route's own screen call it" was right for a route's own screens and wrong for the routes it reaches in-page, so the explorer now declares `externalLink` as a transitive grant, with the comment saying that rather than claiming it opens links. The census pins the pair as a superset; removing the grant reds it. **The seam regexes matched one quote style.** A double-quoted `react-native` specifier walked past both censuses unseen. Both styles now, with the predicate tested directly for the first time. **The discard request outlived its draft.** `asking` stayed set after a save or a revert, so the next edit re-showed the prompt with no Back request behind it. The request is now dropped when the draft it was about goes, adjusted during render rather than in an effect — the shape React Doctor named in the round-1 fold. Red first: save with the prompt up, edit again, prompt is back. **CodeRabbit's keying comment is a correctness point, not the question I answered.** The page learns its route exactly once, out of `init`, so a same-path param change — another file in the same worktree — left the shell mounted and the page still showing the file it was opened on. My comment claimed "the screen reloads the preview from the param either way", which is true only with the shell absent. Both switches key on the whole route now, params included; two tests cover the same-path case and both red on a pathname-only key. `build-mobile-web-app-bundle.test.mjs` hit 601 of its 600-line cap on the way, so the two manifest assertions now share one expected list instead of repeating it. Closures unchanged: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the seam test import the module it is testing Round 3. **The blocker is mine and the reviewer's diagnosis is exact.** The seam predicate test imported an absolute path into this lane's worktree. On CI that module does not exist and it takes the whole `config/scripts` suite down; here it resolved to the same file by accident, so the test was green against a tree rather than against the checkout — which is why reverting the double-quote fix left it passing and the predicate untested. Relative now, and proved: reverting the fix in place reds both double-quoted cases, which is the first time this test has failed for the right reason. Every file this PR touches is grepped for `/Users/` and `orca-lanes`; none carries a path. **Three comments outlived the grant change.** The two lists became equal when the explorer took `externalLink`, so "longer than the explorer's" and "declared with different grants" were both false. Corrected to what is actually true: the lists are equal and the reasons are not — the preview has its own consumer in `MobileMarkdown`, the explorer has none and declares the grant because its rows push to the preview in-page. **The duplicated serializer is pinned rather than imported.** `shellRouteHref` lives in `page-bootstrap.ts` beside the page's RPC client and its document channel, so a native route file importing it would pull both into the app. The copy stays, and a test asserts the two agree on three routes; dropping the empty-search branch reds it. **Recorded, not fixed:** the sidebar `HostScreen` pushes to `/h/<id>/tasks` through the handoff, which is local, so on a tablet the tasks page runs without `native.clipboard.write` from any page route and its copy actions refuse silently. Pre-existing since C2.1 for the worktree list and agent history. Named in the explorer's manifest comment as the known remaining hop, with the fix being a handoff rule in its own PR. The equality pin needed `it.each<BridgeInitRoute>`: the inferred table is a union whose members carry `?: undefined`, which the ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b6e8b1a7b2 |
feat(mobile): serve the tasks screen from the page, with its seams (OTA phase C, C2.1 + C2.5) (#21694)
* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)
`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.
Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.
The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)
Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.
Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.
The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.
No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)
The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.
Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.
The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.
The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.
A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)
The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.
A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.
Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.
Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)
The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.
One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.
No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)
`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`react-native-webview` is a native component with no browser counterpart:
importing it runs a codegen lookup that throws, and the route manifest imports
every route, so one such import takes the whole page down rather than one
diagram.
The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.
Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.
Registered in the overrides, whose gate fails on an unlisted `.web.*` file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)
The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.
The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.
The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.
The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.
The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)
`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.
Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.
The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)
Four from round 1.
The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.
The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.
`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.
`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.
No parity hash moved: none of these files is in the refactor-parity source
set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(config): use endsWith for the clipboard module check
The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)
Five from round 2, two of them real.
The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.
The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.
Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
211821dc17 |
feat(mobile): render agent session history from the desktop's bundle (OTA phase C, C5.1) (#21596)
* fix(mobile): refuse a page target the shell will not take instead of opening it here
`useRouteHandoff`'s web sibling answered two things — handed off, or push it
locally — and fell through to the local router for three different reasons. Only
one of them is a page route. An href the protocol's own pattern drops and a shell
that answered no are the page reaching past what this shell can serve, and the
bundle carries every route under `app/h`, so the fallback does not paint
Unmatched: it mounts `session/[worktreeId]` on React Native Web inside the shell.
The outcome is now tri-state. A target outside `pageRoutes` is never pushed
locally; the page stays where it is and names the reason once per client, which
is the bound the other page-side reporters take.
Proved in the render check against the real bundle: with the double granting no
`navigate`, "Back to hosts" left the host route for `/` and painted Unmatched
before this, and now stays put, posts nothing and reports no page fault.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): encode the host id the worktree row's navigation actions build
Both targets this sheet offers interpolated `hostId` raw — the C1.2 class the
C1.8 stack fixed at the route files and `web.tsx`, at the last two sites that
still had it. `useLocalSearchParams` answers the decoded value, so a deep-linked
id carrying `?`, `#` or whitespace stops being one segment.
It matters more from C5.1 on. Inside the page these targets go through
`useRouteHandoff`, which matches the pathname against the shell's `pageRoutes`
before deciding anything, and the id is the segment the pattern is reading.
The worktree id was already encoded at both sites; this makes the host id match,
and the new test pins all four targets rather than only the one that moved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render agent session history from the desktop's bundle (OTA phase C, C5.1)
The second page route. `agent-history/[worktreeId].tsx` already shipped in the
bundle with its own chunk, so listing it adds nothing to the download and moves
no route count: the switch is `index.tsx`'s, and the shell still decides, because
a bundle naming a grant this app lacks renders the native panel instead.
Its `.web.tsx` sibling is required for `index.web.tsx`'s reason — the native file
reaches OrcaMobileWebShellView, whose requireNativeViewManager runs at import and
takes the whole bundle down in a browser, since the manifest imports every route.
First route with two dynamic segments, so both are encoded. Grants are `navigate`
and `storage`: a resumed session opens the native session screen, the worktree
list now reaches this screen without leaving the page, and `app/h/_layout.tsx`
reads the app's own sidebar width above every page route.
The panel's router becomes `useRouteHandoff`, which is the seam that tells those
two apart: agent history is a page route and is pushed here, the session screen is
not and goes to the shell.
The three writes a resume makes needed no page-side handling and have none. What
they needed was a test that the descriptor's handling survives the extra hop, so
each is run through the bridge and against the same fake directly and the two
verdicts compared: a refused create raises the host's message, and a lost reply or
a shell disposed mid-flight stays delivery-unknown rather than becoming a failure
a user would retry blindly. No golden covers those three.
The flag census grows its first entry since C1.3, which is what it is for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the agent-history Back button to the shell handoff
C5.1 wired this button by swapping the panel's `useRouter` for `useRouteHandoff`;
nothing else was needed, because `RouteHandoff` is the router's own shape and the
seam's web sibling decides `back`. So this commit is the test that would have
caught the wiring being absent, not a fix.
Red before the merge, green after, on the same four cases: on `e897e8123a`, where
`back` was still expo-router's own spread member, 3 failed and 1 passed — the one
that passed is the local-pop case, which is the branch C2.2 did not change. After
the merge brought in C2.2's `back`, all 4 pass. The pre-merge run named the notify
by its literal `'navigate-back'` because the contract constant did not exist yet;
it is the same string `BRIDGE_NAVIGATE_BACK_NOTIFY` holds, so the two runs asked
the same question.
Both module substitutions are the builder's own rather than conveniences: the web
bundle resolves `route-handoff` and `client-context` to their `.web` siblings, so
mocking each to its sibling gives this screen the module graph it has inside the
page. The frames are read off the port pair's lane rather than off a spy, and one
case asserts a frame crossed at all before either absence is read as an answer.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the resume's second write, not only its first
Round 1, finding 1. All three `resumeAiVaultSessionInTerminal` cases settled the
create (`ai-vault-resume-launch.ts:158`), so `terminal.send` had never crossed the
bridge and the half of the resume that types the command into the pane was
uncompared. Three cases now drive both writes: a refused send raises the host's
message, an accepted send reporting `accepted: false` in-band says "Terminal input
is locked", and a send the host takes resolves — the last one being the presence
precondition, since a run that failed at the create would give the same shape of
verdict as one that failed at the send.
Reading `requests[1]` straight after settling the create finds nothing on the
bridged leg: the second write is made only once the first settles, so it is two
more lane round trips away. `nthRequest` waits instead, and says how many it saw
when it gives up, so this cannot pass by proving the opposite of what it says.
The locked reply is `{ send: { accepted: false } }`, not `{ accepted: false }`:
the reader is `reply.send?.accepted !== false` (`review-terminal-reply-schema.ts:65`),
and the flat shape resolves rather than throwing. Written the flat way first, both
legs agreed on "(resolved)", which is the comparison doing its job.
Also finding 1's second half: the file docstring claimed every case runs twice and
differences the verdicts, which was false for the dispose case — a fake RPC client
has no door to shut, so there is no native run to compare against. The docstring
now says so and the case carries the same note.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin what encoding cannot save about a dot-segment id
Round 1, finding 2. The route's docstring listed `/ ? #` and whitespace and the
encoding test covered five ids of that kind, which together implied encoding makes
any id safe. It does not: `encodeURIComponent('..')` is `'..'`, so the pathname
reaches `BRIDGE_ROUTE_PATHNAME_PATTERN` intact, fails the lookahead that stops a
climb out of `/h/` (`bridge-caps.ts:68`, read through `bridge-envelope.ts:117`),
and the shell answers with `reportShellFailure` — a failure screen where the route
would otherwise have rendered the native panel it already has.
Pinned, not fixed, and the docstring now says which. `app/h/[hostId]/index.tsx`
builds its pathname identically and has the same hole, so this series fixing one
of two call sites would leave the shape behind and stop describing it. The new
case asserts both halves — the segment survives encoding unchanged, and the
pattern refuses the pathname — so a later change that starts encoding dots fails
here and has to say which screen it wants instead.
Characterisation, so it was green on the first run rather than red: the claim is
about behaviour that already ships, and the value is that the refusal is on record.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mount the agent-history route in a real browser
Round 1, finding 3. Nothing rendered this route's real module graph anywhere. The
unit tests mock react-native, safe-area, svg, lucide and the icon assets away —
they have to, since react-native is Flow source vitest cannot parse — so a
component in this closure with no web build would have reached a device before it
reached a test. The render check is the only place the graph meets React Native
Web, and this route was not in it.
Two cases. The first mounts the route from the shell double and reads the screen:
"Agent Session History" and the worktree label the params half carried, no fault,
no console error, no CSP refusal, and the URL the page wrote for itself. That also
proves `init.route.params` end to end on a route that has a dynamic segment too,
which §1 of the design claimed and nothing checked.
The second pins the chunk. C5 is the first series whose success path pulls a
second chunk after the first paint, which on iOS goes through WKURLSchemeHandler
under `script-src 'self'`. The chunk is named from the builder's own route map
rather than guessed from the bytes, and asserted absent from what the first route
loaded, so this says the route came over the wire now.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say the real lifetime of the route-handoff refusal set
Round 1, finding 4. The comment claimed one line per reason "for the life of one
client", borrowing `createPageDiagnosticReporter`'s bound. The set is built inside
the `useMemo` keyed on `[client, router]`, so it is per hook instance: in practice
the memo is not recomputed, because `useRouter()` is expo-router's module
singleton and the page holds one client, but every screen calling the hook gets
its own set and a reason can be reported once per screen rather than once per
document.
Says that now, and why it is not tightened: a per-module set would outlive the
page's client, which is the lifetime the rest of these reporters are scoped to,
and there is no document-wide reporter to join without reaching into a contract
file the C2 lane owns.
Records the other half of the finding too, which came back confirmed rather than
changed: `console.warn` is right here. It is the vocabulary `page-bootstrap.ts:35`
already writes in, and a `fault` notify would be wrong twice — the shell drops the
generation on a page fault, and a navigation the page declined is not a failure.
Comment only; no behaviour change, 25 navigation tests unchanged and green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): wrap every router member that takes a target, not three of five
Round 2, finding 1. `...router` hands through everything this file does not name,
and two of the members it did not name take an href: `navigate` and `prefetch`.
`navigate` to a route outside `pageRoutes` went straight to expo-router and pushed
it into this document — the hole the tri-state exists to close, reopened under a
name nobody had looked at. No call site uses it today, which is why it shipped.
`navigate` is now wrapped exactly as `push` is: which of push-or-collapse it does
is a decision about this document's stack, and a target outside this document has
no such stack.
`prefetch` is decided the other way, explicitly. It is the one target-taker that
must never reach the shell: a prefetch is a background load, `navigate` is the
only thing the shell can be told, so handing one over would open a screen nobody
asked for. A route this document serves is prefetched here, which is what the
per-route chunk split makes worth doing; every other one is dropped without a
line, because a warm-up that did not happen is not a failure to report.
The docstring's "four members that can leave this document" is now five wrapped
members and a rule for which is which.
A list would rot, so the pin is derived: `HrefTakingRouterMember` reads the
parameter tuple of every member of `RouteHandoff` and `WRAPPED_HREF_MEMBERS` is
asserted equal to it in both directions. It reads the tuple rather than testing
assignability because `() => void` is assignable to `(href: RouterHref) => void`,
which would make `back`, `dismissAll` and `reload` target-takers and prove
nothing. Checked both ways: dropping `prefetch` from the list fails the compile
with "Type 'HrefTakingRouterMember' does not satisfy the constraint", and the
union resolves to exactly the five, with `back` and `setParams` outside it.
The pin is in the product module because `mobile/tsconfig.json` excludes tests.
The runtime test asserts each wrapped member is not the router's own function and
that `setParams` still is, so a hook that wrapped everything fails too.
Red first: 4 of the new cases fail against the previous file, 33 pass now.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): say per hook instance in the title too, not per client
Round 2, finding 2. The source comment was corrected in round 1 and this test's
title was not, so the two disagreed about the bound the refusal set actually has:
the set lives in the `useMemo`, so it is per hook instance, and a title claiming
per client is the stronger promise the code does not make.
Title only; the case and its assertions are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the agent-history render case does not cover
Round 2, finding 3. The docstring claimed the case is where the panel's closure
meets React Native Web, which overstates it. The shell double answers no RPC, so
the session scan fails and the panel paints its "Unable to Load" state: the
session list, its rows, the resume button and the scope tabs never render, and a
render-time gap inside any of them would pass this check.
Now says both halves — import-time evaluation of every module in the closure and
the panel's own chrome are covered, the list subtree is not — and names what
covering the rest would take: a double that answers `aiVault.listSessions`, which
is a different instrument and would put domain behaviour in this file.
Text only. This case moves to its own file on the extracted harness after the
merge with #21592; the corrected text travels with it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): import the handoff module once in its own test
My round-2 fold added `WRAPPED_HREF_MEMBERS` as a second import of
`./route-handoff.web`, which `import(no-duplicates)` fails in the focused-plugins
pass of the changed-code gate. Joined to the existing import below the mocks,
which is where an import of the module under test has to sit in this file.
Found by running the changed-code gate rather than by review: mobile tsc, whole
tree oxlint and the suite were all green with it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give agent-history its own render file on the extracted harness
The render check gained browser cases from three domain series at once, each
under the `.mjs` cap of 600 counted lines alone and no two of them together: at
|
||
|
|
edd9e3125b |
fix(mobile): give the page its height and its long press; C1.7 device proof (OTA phase C, C1.9) (#21589)
* fix(mobile): give the Route A document the height its mounted tree measures against (OTA phase C, C1.9) The document this builder emits carries no stylesheet, so `html`, `body` and `#root` have no height, and every box react-native-web lays out below the mount is `flex: 1` against a parent that measures 0. The collapse is silent in every check that existed: the entry stamps `mounted`, the route tree commits, `innerText` holds every row, and the accessibility tree reports each one at the offset it would have had. Nothing is painted below the header, and nothing takes a tap — the list sits inside a scroller the collapse clipped, and a phone reads it to VoiceOver while no row responds. Lane C1.7 found it on both an iPhone 17 Pro simulator and a Pixel 9 Pro emulator, and the same bytes reproduce it in headless Chromium. The fix is the reset Expo's own web template ships for a react-native-web root, emitted inline because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet react-native-web injects at runtime; a linked asset would paint the collapsed layout until it landed. The render check gains the assertion that would have caught it: the root's box measured against the viewport, and the one control this route paints with no RPC answered — the New Workspace button, positioned against the bottom of the root, which the collapse moved to y = -72 — asked for by `elementFromPoint` at its own centre. Laid out is not reachable, so the check is a hit test and not another read of the DOM. Without the reset it fails `expected +0 to be 844`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the page have the long press WebKit was taking (OTA phase C, C1.9) The shell's WKWebView is built with the default text interaction, so WebKit installs its selection assistant over the page. A hold on a worktree row raises the selection loupe over the row's own text and the touch is cancelled before the page's responder sees it, which leaves every long-press action in the page dead on iOS while a tap works. Lane C1.7 measured it: the same injected hold opens the row action sheet on the native list and on the page in the Android WebView, and does nothing in the page on iOS. It is not the document's to fix, which the device disproved one rule at a time: `-webkit-touch-callout: none`, `-webkit-user-select: none`, and both together all left the loupe and left the hold undelivered, and headless Chromium confirms the property computes to `none` on the page's text, so the CSS reaches it and WebKit's own gesture wins anyway. The cost is real and named here rather than discovered later: the page has no text selection on iOS, so selectable `Text` — markdown, diff rows, file preview, chat — cannot be selected there until a page-side copy affordance exists. Everything the shell already forbids is unchanged, and Android is untouched. No unit test: the module's Swift checks compile the seven WebKit-free logic files and never import WebKit, so a `WKWebViewConfiguration` cannot be built in them. The device proof stands in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the root reset the template's bytes, not a copy with an addition The comment said the reset is what Expo's web template ships, and `margin:0` was not in it. `@expo/cli@55.0.36/static/template/index.html` carries height, `overflow` and the root's flex box and nothing else, and react-native-web emits `body{margin:0}` in the sheet it injects at runtime, so the addition only covered the frames before that sheet landed. Nothing pinned it either: removing it left all 60 tests green, which is the other way of saying it was never load-bearing. Dropping it makes the string one thing with one source instead of a copy to keep in step with two. The pins on the rest of the reset are unchanged, and so is the frame that mattered: the root still has a definite height before the first paint, which is what the collapse needed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the render check with the root formatter `config/scripts` is formatted by the root oxfmt, not mobile's, and CI checks neither, so a 102-char line I added sat over the root's `printWidth: 100` with nothing to catch it. Reflowed by `./node_modules/.bin/oxfmt --write` from the repo root; no behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what the root reset shares with Expo's template, not that it is its bytes "The bytes Expo's web template ships" is false and checkable: the template's own block is pretty-printed with comments and trailing semicolons at 410 bytes, and this string is 112. What is actually true, and what the next reader needs, is that it carries the same declaration set and the same `id="expo-reset"`, minified. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin every rule of the root reset, not two substrings of itself The check read the constant back against itself: `toContain(MOBILE_WEB_APP_ROOT_RESET)` plus two substrings taken off that same constant. A rule dropped from it took the assertion with it, so `body{overflow:hidden}`, `flex:1` and the `expo-reset` id were unpinned — and the render check stays green without the overflow rule, so nothing else held them either. Each rule is now a literal written here, named one at a time so a failure says which one went, and the id is pinned beside them. Verified red-first: removing the overflow rule, the `flex:1`, or the id each fails this test and only this test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e2afb5eef9 |
feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) (#21503)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) Three gaps the census named, and the last of them is why the page needed a second grant. `expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with an empty array and the list painted "Host not found" over the host the shell had just opened it for. `init` gains an optional `host`, and `host-store.web.ts` serves it: the profile the screens read, with no device token and no public key, because the bridge already carries the connection those would have opened. The native writes it cannot make — pairing, renaming, recency — settle rather than throw, since recency orders a list the page never shows. AsyncStorage's web build is `window.localStorage`, and the page has none worth having: Android turns DOM storage off and on iOS the origin host is the session id, so a pin set in the page was gone on the next remount. The builder aliases the module to a page store whose values are the app's own — `init` primes the allowlisted keys, a write is applied locally and posted over a new `storage` grant, and the app is where it lands. The allowlist is two keys and is the whole fence: everything the app stores shares one namespace, the hybrid shell flag included, so a page that could write any of it could turn the feature on for a build that never offered it. A key outside the list is refused and, crucially, not kept locally either — a pin that looks set and is not is the failure the grant exists to avoid. The bridge host is built only once both have been read, because `init` is answered once per `ready` and carries them: a host that started without them would have to be torn down to carry them, and the list would already have mounted against a host it could not name. `Alert.alert` on a failed host removal is a silent no-op in React Native Web, so inside the page that failure had no surface at all. It routes to the error the list already shows, on both platforms. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the storage-write ref after the commit The last render-phase ref write in this hook joins the commit-phase effect. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the confirm setter this callback already calls in its deps A `useState` setter is stable, so the identity of the callback is unchanged; the list now says what the body reads. Reported on the line this branch rewrote. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4) Five, from one round of review. A removal that fails no longer writes the identity error: that one is an early return over the header, the list and the overlays, with nothing to dismiss it and nothing left to render the confirm it re-opens. It goes to a dismissible line above the list instead, on both platforms, cleared by the next confirmed refresh. `init` reads the allowlisted keys on every answer rather than capturing them at mount, so a document that reloads inside one mount is primed from after its own writes. The read stays synchronous: the page refuses every member until `init` lands and the golden recorder mounts a screen in the same turn it drains one, so a promise here moves the first render of every bridged replay. A profile read that rejects is now a shell failure with a diagnostic instead of a `ready` session with no host behind it and a page asking forever. The page bounds a value by the envelope's own constant rather than caching what the wire drops. And a write is held to the keys this page was handed, so one host's page cannot rewrite another's pinned list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): state the two test fixtures' types instead of asserting them The casting gate reads a `SAFETY:` rationale off the line directly above the assertion, and a wrapped comment puts a comment there instead. Two of the four were not assertions worth keeping at all: a hoisted fixture says its own type, and the router comes from the mock the file already installs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the two fixtures in place, not through a widened binding `const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate refuses, and rightly: the evidence is discarded and then invented again. The assertion belongs at the literal, with its rationale on the line above it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the key a refused page write reached for The last diagnostic still falling through to the line about a view outliving its host, on the branch that added it. The key is the evidence: it says which host's pinned list the page was reaching into. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe this branch's storage writes too Same merge, one branch further: a probe here also collects what the page asked the screen to write. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the page the app's storage as it stands, not one init late The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes two of them from its own native screens. The shell's map was only re-read after a ready, so a native write between two readies reached the init after next: the drawer opened on the repo the user left. The map is now module-scoped and every writer of an allowlisted key notes it as it writes, so the init that answers a ready already carries it. The store read only seats the map, and a read that started before a write no longer puts the older value back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the banner's dismiss without an assertion Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the storage mirror in the storage tree The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the dependency the wrong way round: the shell is what is built on the app's storage, not the other way. The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ac4dc6599b |
feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
209d2d8df6 |
build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) (#21475)
* build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) The page bundled as one 8.16 MB script because every route was a static import. The route manifest now defers each screen behind `import()`, the build is esm with splitting on, and the document loads the entry as a module. What the browser parses before the first route can paint drops from 8.16 MB to 908 KiB; the whole page still weighs the same. Two budgets hold it: the chunk count, which catches a split running away, and the bytes the entry reaches by static import, which catches it collapsing back. The second is the one that matters, and it is measured from esbuild's metafile because only that says which import is static. The RequireContext stays synchronous, since expo-router reads keys() to build the route tree before anything renders. A lazy module cannot answer `unstable_settings` or `ErrorBoundary`, which expo-router reads off the namespace, so a test holds that no route in the subtree exports either. The render check now waits for the route's own text: the entry's mount signal lands while the route chunk is still being fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a route's synchronous exports from esbuild, not a regex `export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all reach the namespace without matching the declaration pattern the guard was matching, so the lazy manifest dropped the boundary and the page painted blank. A star re-export is now reported rather than read as clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say that the entry budget is not a per-route opt-out Measured: statically importing one route already breaks the 3 MiB bound for 5 of the 14. The hatch only works for a layout node, which is the only place expo-router reads a synchronous export from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the chunk ceiling from the route count 64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's routes would have failed on a number measured before they existed. Four per route plus 16 tracks the measured slope; the entry-bytes bound stays the real budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the web entry's dead suspense boundary expo-router wraps every screen in its own, so this one never fires; all nine render checks stay green without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a client-side navigation fetches the next route's chunk Goes red with splitting off: the tasks screen paints out of the entry and no new script is fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name every bundle output by its bytes, not by esbuild's path hash esbuild's [hash] is over the metafile's input keys, which are paths relative to absWorkingDir, so a checkout at another depth or with node_modules as a symlink named a byte-identical chunk differently and shipped a different buildId for one commit. Outputs are now renamed leaves-first to the sha256 of their final bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build on a route the lazy manifest would strip The guard ran only in a test while the docstring said it failed the build. It now runs in bundleMobileWebApp and names the route and the export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the asset ceiling from the chunk ceiling and the images A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset count would have failed first and named the count instead of the split. Chunks plus images plus the document keeps the chunk ceiling the one that trips. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the route-manifest tests out of the bundle builder's The builder's test file passed 600 lines. The route manifest, the synthesized RequireContext and the web entry are their own subject and move together. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the export guard the builder's route-source loaders Without .js as jsx the guard reported a React Native .js route carrying JSX as "JSX syntax extension is not enabled" instead of reading its exports. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the navigation fetches the tasks route's own chunk "some new script arrived" passed on any fetch. The builder now names the chunk each route lands in, read off the metafile, and the check asserts that exact path arrived and was not already loaded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): resolve a route's realpath before matching it to its chunk esbuild writes metafile input keys after resolving symlinks, so every scratch route tree under /var on macOS reached no output and failed the build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build when the asset ceiling outgrows the shell's map The derived ceiling had no upper bound, and the native shells return null for a manifest over their own 256 rather than truncating it. At 42 images the formula crosses that at 50 routes, inside what Phase C adds, so the build would stay green while the phone got nothing. The number is read from the contract through esbuild, not restated here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the two hard stops in the content-addressed naming Both throws only ran through a whole bundle before, where neither can be provoked. A cycle and a route no output claims are now asserted directly; each test goes red when its throw is removed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): exit the app-bundle build on one line, not a stack The route-export guard fails this script by design, and a raw stack put the route and the export name under twelve frames of node internals. Mirrors the verifier's exit; the message is printed as thrown because every throw on this path already names its source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
381a3da46f |
feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449)
* refactor(mobile-web): share the bundle manifest assembly with a second builder Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and the helpers the Phase C app builder needs become exports. No behaviour change to the shipped bootstrap bundle. The CRLF guard grows two exemptions it needs once it is pointed at mobile/src: the image and font extensions .gitattributes already pins -text, and the gitignored webview engine modules the postinstall writes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): web entry for the host route tree, and its two transport siblings The entry mounts app/h on react-native-web through expo-router's own ExpoRoot. It lives inside mobile/ so one React resolves, and supplies RpcClientProvider itself: the route tree starts below the native root layout that owns it. route-manifest.ts is a real typed module whose body the builder replaces -- esbuild has no require.context. A virtual specifier would need an ambient declaration and would leave the entry unchecked. Two .web.* siblings, both listed with a reason in web-overrides.json: the transport substitution point (a placeholder client until C0.4 lands BridgeRpcClient) and the device token store, whose native path imports expo-secure-store, which is {} on web. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): build:mobile-web:app, the phone's host routes bundled for the web Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app, with the same manifest and the same two-scratch-build determinism check. Dark: build:mobile-web, packaging and the A2 census are untouched, and C1 is what flips build:release. Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin hashed assets rather than data: URLs, because the shell's CSP sets img-src 'self'; the render check under that exact header is what found it. The script is referenced root-absolute for the same reason a <base> tag cannot be used: the document is served at every route depth and base-uri is 'none'. The budget sits below the contract's per-asset ceiling so growth trips a build rather than a refused asset on a phone. esbuild splitting does not lower it: one entry with only static imports emits one chunk (measured). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let React Native Web paint under the shell CSP RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so style-src 'self' blocks every rule and the page renders unstyled. Measured, not predicted: the render check serves the document under this exact header and reported the violation. 'unsafe-inline' is granted to style-src and nothing else. script-src 'self' holds, which is the directive that decides whether page code can arrive any way other than as a fetched same-origin script. The test now pins that scoping rather than rejecting the token everywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: prove the Route A app bundle on every PR A dedicated job, for the same reason the browser provider has one: it needs mobile/node_modules and a real browser, and the sharded test matrix would pay for both on every shard. It builds the bundle, verifies it, and runs the builder, override-census and render suites. It ships nothing. The mobile_web_app signal is lifted out of should_run the way static_analysis is. A mobile-only diff is desktop-irrelevant and skips every gated job, and that is exactly the diff that changes the page this job builds. Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml join the installer cache keys in the two workflows that build an installer off a hashFiles key, since beforePack requires out/mobile-web and a mobile-only change must miss those caches rather than reuse a stale build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): pin the shipped builder against the app builder's own module name The assertion named a specifier that no longer exists, so it held vacuously. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the RN Web style-src grant in the Swift checks The Swift twin of the Kotlin CSP test still required style-src 'self' and no unsafe-inline anywhere, so it trapped on the approved grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): make the Route A render check name what each route paints The check asserted only "some html, no errors", which expo-router's Unmatched screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green. Each route now asserts content only its own component produces, and the unmatched case asserts the screen positively so the negatives discriminate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): read the shell CSP past the comments that quote directives Both constants document themselves with // comments containing quoted directive text, which the quoted-string scan picked up as directives. One parser now drops comment lines, and iOS and Android go through it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): honour a .web.* route sibling in the app bundle Routes were imported by absolute path with the extension, so esbuild's resolveExtensions never applied and a .web.tsx under app/ was dead code the census still accepted. The manifest now carries a key and a module: the key stays the native filename so the URL does not move, and the module is the web sibling when one exists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): tie each named shim to the esbuild option that implements it The shim list was asserted against a literal copy of itself, which passes however the build is configured. Each entry now carries an appliesTo that reads its own option, checked against the real options object. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(build): line up the CRLF exemptions, the budget comment, and the job scope The builder loads .gif as a file but neither .gitattributes nor the CRLF scan exempted it, so the blanket eol=lf pin would have rewritten one. A test now keeps the two lists in step. The Phase C byte budget's comment sat on the asset count, and a root package.json edit could change build:mobile-web:app without running the job that proves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): satisfy the index-check lint rule in the CSP parser Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: key the installer caches on the mobile page trees too beforePack builds the mobile web bundle into the installer. Today those bytes are Phase A's, which src/** already covers, but once C1 flips the entry to mobile/app a page-only change would hit a cache holding a stale installer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): skip the bundling tests where mobile dependencies are absent The sharded `test` job collects config/scripts/**/*.test.mjs and installs no mobile dependencies, so the two new suites failed there on "Could not resolve react-native-web". They now skip themselves with a message naming the job that runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing install fails it instead of skipping everything it exists to prove. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): scan mobile/packages in the .web.* census The census claimed the app entry never resolves into packages/, but the dictation hook imports @orca/expo-two-way-audio and the built script carries ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and planting a .web.* in each scanned tree proves the scan is not passing because a tree happens to be empty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): assert the route exclusions against a tree that has them mobile/app holds no test, spec or +api file, so the exclusion rule was asserted against a tree it could not fire on. A scratch tree plants one of each; dropping the rule now fails this test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): 404 unknown file paths in the render check's page server The server answered every path with the document, so pointing publicPath at /wrong-prefix still rendered three green routes: the script is fetched from the one prefix that is served. A path naming a file now has to come out of the bundle, which is what the shell's manifest map does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): cover the app bundle verifier's own checks The verifier had no test. One doctors the buildId, which the packaged assert catches; the other rewrites the tree so every digest still agrees and only the two fresh builds can tell, which is what a stale out/ looks like. Deleting either check now fails a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(build): tidy the app bundle comments and the job's path prefixes Drops an export nothing read, merges two comments that had drifted apart from the constant they describe, and corrects the claim that the job runs on every PR when it is path-gated. package.json leaves the prefix list because GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it, since the page resolves a .web.ts out of there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): merge the duplicate node:fs/promises import in the census Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): redirect the hybrid shell route on the web page app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import. In a browser that throws before React mounts, and the route manifest imports every route statically, so one native route left the whole page blank at every URL. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): fail the render check with the error that stopped the mount The check waited on "#root has children" with Playwright's animation-frame polling, so a route module that threw at import read as a bare 30s timeout naming nothing. It now waits on a mount attribute the entry sets after the router commits, polls on a timer, and races the wait against the first uncaught error so the failure carries it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): answer the favicon the render browser asks for CI resolves the runner's Google Chrome, which requests /favicon.ico; the bundled headless shell does not. The bundle carries no icon, so the server answers 204 rather than turning a browser habit into a console error the render assertions read as a page fault. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): settle the render check's uncaught-error race without rejecting The entry throws during goto, before anything awaits the race, so a rejected promise surfaced as an unhandled rejection beside the real failure. The same signal now resolves with the error. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the page transport in the raw request port inventory The placeholder client implements the port, so the boundary test counts it as an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient replaces it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |