mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
744e7722c28016dae3a8bc2d2aae2dd901c598ca
67
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
85f8d6b5f5 |
test: retire long-tail cases whose assertion is decided by the test itself (#24132)
Resumes the backlog sweep at a chunk size that actually gets read. Six auditors, 84 files
each, and all six read their full scope case-by-case against production — the first wave
where every chunk closed with no gap. 33 case declarations removed across 22 files, 1 test
file deleted, 826 lines gone. No production code touched.
This wave exists because a conclusion of mine was wrong. I had recorded that yield collapsed
~36x and that deletion was no longer the high-value work. I was dividing cases removed by
files IN SCOPE while the fraction auditors actually READ fell from 100% to about 4%, because
I kept handing them 300-800 files. Recomputed against files read, yield has been flat at 4-7
per 100 with no downward trend. This wave came in at 8.2.
The most instructive removal looked like the most valuable test in scope.
`orchestration-worker-release-reap-fixed.func.test.ts` cites a production bug by two
identifiers, describes orphaned PTYs accumulating until `TasksMax=4096` aborts processes on
EAGAIN, and advertises itself as the functional tier wiring the real orchestration RPC
surface, the real `OrchestrationDb` and the real release modules. Deleting it leaves no
reference to that bug anywhere in `src`.
It still had to go: its fake runtime performed the fence it asserted —
if (pty.incarnationId !== inc) { return null }
handleTable.set('term_reminted', { ptyId, epoch: rendererGraphEpoch })
— so the case checking that a reused ptyId with a mismatched incarnation does not resolve was
checking a decision its own spy made twenty lines earlier. The real fence is owned by
`orca-runtime-terminal-handle-incarnation.test.ts:257`, and the other two cases replay
`orchestration-worker-release-incarnation-fallback.test.ts` (which uses a plain
`mockReturnValue` rather than reimplementing the remint) and `worker/worker-release.test.ts:23`.
"Integration test" and "wires real modules" describe the scaffolding, not the asserted step.
Other removals: a self-comparison disguised by an alias, where
`export const getIssueOwnerRepo = getOwnerRepo` makes a case asserting the two "agree" into
`f(x) === f(x)`; four cases whose `vi.mock` of `resolveIssueSource` made both the preference
value and the topology inert; five verdict-precedence cases owned by a verdict-agnostic block;
three call-shape probes on one-line store pass-throughs whose real contracts are driven by
behavioural neighbours; and a `export type _Ref = [...]` declaration whose own comment admits
it exists only to preserve test-only module-surface references.
Kept after checking production rather than shape. An auditor found two near-identical
ten-reconnect loops and kept both: one uses a test-local live-lease filter, the other the
shipped `sshRemotePtyLeaseAllowsReattach` predicate, and the file's own comment explains the
duality is deliberate "so the two cannot drift". Another kept a paths-alignment case that
looks like a validator tested against its own list, because adding a generated file without
registering its path does fail it — and `shellReadyWrappersExist` uses that registered list to
decide whether a partial tree needs regeneration.
Production duplication is now confirmed four times over, and it is why mirrored tests exist:
`createUpdateWorktreeLineage`/`createAssignWorktreeParent` differ by one `console.error`
string; `terminal-path-tap.ts` and `document/path-tap.ts` carry hand-maintained copies of
`matchFilePathAtColumn` under a docblock reading "keep the two in sync". In those cases both
test sides are load-bearing and the duplication belongs on a refactor list.
`mobile/tests-typecheck-baseline.txt` loses one entry. Trimming
`relay-host-signed-out-verdict.test.ts` made it typecheck clean, so the ratchet required
pruning its grandfathered entry — the file graduates from exempt to enforced. Baseline is now
124 entries, down from 125.
Verified: 690 test files / 7,560 cases pass across the touched desktop areas; the modified
mobile files pass (162 cases); `check-tests-typecheck-ratchet.mjs` OK (898 files in program,
124 grandfathered); `check-reliability-gates.mjs` 140 gates; the deleted file is absent from
the gate manifest, `cloud/package.json` and the mobile baseline; nothing under
`mobile/src/test-support/rpc-recording/` or `mobile/rpc-foundation/goldens/` touched.
|
||
|
|
cef66fbab8 |
test: retire long-tail cases whose input cannot reach the behavior they name (#24101)
Sweeps the triage-only backlog: 2,269 files that earlier waves saw and skipped for size, reconstructed from the unread lists five waves of auditors disclosed. 35 case declarations removed across 15 files, 2 test files deleted, 487 lines gone. No production file touched. These are large integration suites, so the junk here is individual cases buried among real coverage rather than whole bad files. The dominant defect was again a case whose input cannot reach the behavior its title names: - `resume-sleeping-agent-session-remote-compat.test.ts` (deleted) — two cases titled for "transport-level host authority on a capable host" and "host authority is not known". `resume-sleeping-agent-session.ts` has no host-authority or capability concept at all, and its only read of `origin` is `if (!record.origin && record.state === 'done')`, unreachable for both rows. Both executed one identical path. The surviving contract is owned by `resume-sleeping-agent-session-execution-host-scope.test.ts`, which drives a real host catalog. - `project-group-header-drag.test.ts` (deleted) — four cases setting `data-project-group-header-id`, which the predicate never reads. Its subject, `isProjectGroupHeaderActionTarget`, is byte-identical to `isRepoHeaderActionTarget` apart from the function name and imports the same `REPO_HEADER_ACTION_SELECTOR`, so all four cases were a strict subset of `project-header-drag.test.ts` using identical `data-repo-header-*` fixtures. - `remote-worktree-history-cleanup.test.ts` — "repeats idempotent cleanup through the PTY owner" against a six-line best-effort forward with zero dedupe state. The case called it twice and asserted the mock recorded two calls, which is arithmetic over the test's own loop; nothing about idempotence was established. Also removed: - Runtime assertions of type-level facts, where production already makes the check at a stronger boundary: `const adapterSatisfiesPort: AdapterIsPort = true` followed by `expect(...).toBe(true)` — unconditionally true, while `createExpoGenerationFileSystem(): GenerationFileSystem` is explicitly annotated and passed into `createGenerationStore` at a typed call site. And a case named "does not typecheck" whose runtime assertion is a length check on its own literal, declaring its own local annotation so it could never notice the production annotation weakening. - Private predicate tests duplicated at a real boundary: four `repo-slug-cache` cases delivered by `repo-slug-index.test.ts`, which drives the same resolution through the hook, the real store and the preload bridge, while the cache-level versions hand-seed the internal map and break on a cache-key format change. - Duplicate invocations owned at the shared boundary, including commit and push recovery cases owned by `src/shared/source-control-recovery-agent-command.test.ts`. Kept deliberately, verified rather than assumed: the production duplication behind the deleted drag test was left alone, because `REPO_HEADER_ACTION_SELECTOR` ends in generic `button, a, input, textarea, select`, so genuine action targets inside a group header still match — it is an unspecialised copy-paste, not a live bug, and collapsing two functions is a refactor. Reported instead. Auditors' probes produced 20, 11 and 13 candidate hits for the signature-versus-title shape across their chunks; every one was inspected and every one was genuine coverage. No deletion in this wave rests on a probe alone. Coverage is partial and stated as such: of 2,269 files, roughly 100 were read case-by-case and the remainder reviewed at title-plus-import level. Each auditor listed its own unread set. The largest remaining surfaces are `src/main/agent-hooks` (95), `src/main/claude` (100), `src/renderer/src/lib/pane-manager` (62) and the 20 largest sidebar suites. Verified: 2,583 desktop test files / 25,636 cases pass, plus one pre-existing `it.fails` marker; the two modified mobile files pass (57 cases); `check-reliability-gates.mjs` 140 gates; `check:code-quality:changed` 0 new findings. Both deleted files confirmed absent from the gate manifest, `cloud/package.json` and `mobile/tests-typecheck-baseline.txt`. |
||
|
|
fed1eca486 |
test: stop restating internal tuning constants, keep the ones that are contracts (#23950)
Removes ~74 assertions of the form `expect(SOME_CONSTANT).toBe(<literal>)` where the literal is an internal tuning value — a timeout, retry count, debounce interval, cache TTL, circuit-breaker window, Tailwind class string. Those cannot fail for any reason a user would notice: they fail only when someone deliberately changes the number, and then the test is simply updated. They are copies of the declaration. The same pattern is NOT junk when the exact value is observable outside this process, so those were deliberately kept: - terminal byte contracts: `\r`, `\x03` ETX, Kitty escapes, `\x1b[?1;2c`; - wire and capability values: `agent.launch.v2`, protocol 3 / min-compatible 2, daemon per-feature boundary versions (a daemon survives app updates, so those pin what an old field daemon may be trusted with), relay header tokens; - security invariants: the `127.0.0.1` bind default, an empty iframe `sandbox`; - values external processes read: exit code 78 (EX_CONFIG) and exit code 3 (systemd `RestartPreventExitStatus`), `ORCA_AGENT_SESSION_SPAWN_TOKEN`, `npx skills …` commands users paste, on-disk journal schema versions, the `orca_<hash>` filename prefix the fish sweeper matches; - third-party names: expo-router's `unstable_settings` / `ErrorBoundary`, iOS Safari's 16px zoom threshold. Where a case asserted a relation rather than a literal — `A < B`, a sum of parts, a cap compared against a sibling budget — the relation stays and only the literal went. Test-only changes: no production file is touched and no test file is deleted. |
||
|
|
2d31941286 |
fix(mobile): the update-mobile wall opens the exact release (#23789)
* fix(mobile): the update-mobile wall opens the exact release When the in-app checker knows the newest release, the update-mobile wall offers "Get Orca <version>" and opens that release's page instead of the generic releases list or a hand-built App Store link. Mounting the wall runs the single-flight, bounded checker once. Dismissal is ignored here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the update checker out of the page closure The wall now takes the offered release as a prop and opens it through the external-link seam. A shell-only hook runs the checker once per update-mobile wall and supplies the release; MobileWebShellScreen, which no page route reaches, wires it. HostProtocolGate is in every page closure, so it stays unwired pending a ruling. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): platform-split the wall's release offer and throttle its check The hook is the one module whose behaviour differs by platform: the native file runs the checker, the .web.ts sibling offers nothing, so both HostProtocolGate and MobileWebShellScreen wire it the same way and the page closure never carries the checker. A remount checks only when no release is known and the last check is absent or over the retry interval old, since each check is an unauthenticated GitHub call. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the checker decide whether the wall's check is due The hook's own throttle read lastCheckedAt, which moves only on success, so after a failed lookup every wall remount looked up again, and before preferences loaded a cold-start wall looked up despite a fresh stored result. The checker already owns the cadence: checkIfDue waits for the stored state and checks only past the retry or daily interval. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the wall reads the known release and triggers nothing The started checker's own timer, cold-start and foreground paths already run every due check, so a check requested by the wall could never be due. The hook now only returns the known release; the checker is back to main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the wall reads its release through useWallAppUpdate The .web.ts sibling alone keeps the page closure clean, so the screen calls the hook itself and the optional prop, the gate wiring and the shell wiring go. HostProtocolGate and MobileWebShellScreen are back to their base content. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
c634432acd |
fix(mobile): count opening a cached page generation as use; cache six hosts (#23780)
* fix(mobile): count opening a cached page generation as use; cache six hosts Eviction order was written only when a download committed, so a host opened daily but downloaded long ago was evicted first and each revisit cost a full redownload. An open now refreshes that host's recency inside the store queue. The ceiling rises to six hosts and the update-failure cap to six hosts' worth. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stamp page cache recency by order and never clobber it on open An open stamped wall time, so a backward clock jump made the host in use the oldest entry and the next download evicted it. Recency now stamps past the newest entry. An unreadable or torn index read as empty, so every open rewrote it with one host and demoted the rest; opens now leave it for a commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): wrap the page cache index doc comment to 100 columns Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): skip no-op page cache recency writes and stage the index write Every open rewrote hosts.json even when the host was already the newest, so a single-host user paid a native write per open. Opens now write only when the order changes. The index is written to a sibling and renamed over the old one, so an interrupted write leaves the previous index or none, never a torn file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep page cache recency as an ordered host list Eviction needs order only, so hosts.json is now the cache keys least recently used first. That drops the clock, the monotonic stamp, the unreadable-versus- absent split and the staged write: a torn or old-format file reads as empty, the same state a missing one gives. Opens and same-build commits share one touch that writes only when the host is not already last. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
70475e0228 |
test: stop testing private internals through exports no caller needs (#23829)
Third audit wave. The detector looked for production modules exporting three
or more symbols that no production file imports — only tests do. That shape is
the authoring gate's fourth question failing: a test needing a production seam
no caller needs belongs at the real boundary instead.
Most hits were detector false positives and were left alone; the scanner misses
re-export barrels and dynamic imports, so every module was re-verified with rg
before any edit. Where a private predicate's behavior was already covered
through the module's real entry point, the duplicate cases are gone and the
symbol is module-private again. Where it was NOT covered anywhere else, the test
stays — this audit removes tests, it does not author replacements.
Production code deleted where tests were its only callers: the superseded
`filesystem-directory-listing-limit` module, the unused
`format{Hourly,Daily,Adhoc}Version` helpers and their orphaned prerelease
identifiers, the dead `filterByAutomationListSearch*` family superseded by
`matchAutomationListSearchRowKeys`, and the dead
`getAiVaultResumeWorktreeTargetStatus` copy of the live workspace branch.
Also drops two call-shape source greps in `relay-sweep-schedule.test.ts` that
asserted `index.ts` spells `jitteredSweepIntervalMs(30_000)`; the jitter math
has a behavioral owner at the top of the same file. The structural census that
counts role-gated vs total `setInterval(` calls stays — an ungated sweep runs in
every cell, and nothing else can catch that.
|
||
|
|
31012aeb09 |
test: remove assertion-free probes, copied inventories and export-shape checks (#23816)
Second audit wave, targeting three more junk patterns: - assertion-free cases that run code and assert nothing, so they pass no matter what the code does; - inventory literals re-typed from a production declaration, where the only way the assertion can fail is someone editing one of the two copies; - export key-set and export-shape loops (`typeof x === 'function'` over every export) that restate what TypeScript already enforces. Yield is much smaller than wave 1 on purpose: the assertion-free scanner has a high false-positive rate, because many flagged blocks assert through a shared helper or their oracle is "this must not throw". Those were kept. `mobileWebCheckArgs` in `config/scripts/run-mobile-web-app-checks.mjs` is de-exported — after the inventory comparison went away, nothing outside the module read it. |
||
|
|
6e1b7e7fa3 |
test: remove junk tests that assert source text instead of behavior (#23815)
Deletes 101 test files and trims 112 more, all matching documented junk patterns: exact source/import/string greps, copied inventories and export lists, duplicate invocations of a contract another test already owns, typeof-shape checks TypeScript already enforces, and self-comparisons. The largest group read a production `.ts` file and asserted on its text — for example a TaskPage test that required the source to contain `selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null`. Any behavior-preserving rename broke it; no behavior change ever did. Production-side follow-through: exports that only these tests imported are de-exported or deleted, stale comments pointing at removed censuses are dropped, and the reliability-gate registry, `cloud/package.json` test lists, and orphaned source-reading helpers are updated so nothing references a deleted file. Two files kept their real coverage and lost only the census scaffolding: `agent-status-producer-census.test.ts` now drives all five producers end to end instead of grepping the source tree, and `config-toml-trust-stale-writes` replaces an export-list parity check. |
||
|
|
29c7d5d983 |
fix(mobile): start AI-button agents through agent.launch, never a bare shell (#22762)
* fix(mobile): start AI-button agents through agent.launch, never a bare shell "Fix checks with AI", "Resolve conflicts with AI", commit-failure recovery and diff review's "New Agent Session" created a terminal with no agent and typed the multi-line prompt into the shell, so each line ran as a shell command. They now call agent.launchReplay into the existing workspace with the prompt; the host picks chat or terminal from the user's default and delivers the prompt. Hosts without the launch capabilities get the buttons disabled with update copy. The agent comes from the desktop's own resolution (moved to src/shared). The replay loop and capability read are shared with the workspace-create launch. * test(mobile): repin bridged-parity tallies for the AI-button launch goldens The corpus goes from 787 to 790 goldens: five shell-path goldens are removed and eight agent.launch ones added; one lands in identical and two in result-absent-settlement. * test(mobile): re-record goldens for AI-button launches through agent.launch Repinned baseline to |
||
|
|
ba858ee446 |
feat(mobile): the keyboard covers the page like a native screen, and the shell says its height (#23110)
The shell no longer shortens the WebView for the keyboard; it publishes the keyboard height like the safe-area insets, so native's keyboard lift, refit hold and dismiss key run on the page unchanged. Keyboard and inset arithmetic read the shell's OS through a host-os seam. One page-version floor (manifest pageVersion, shell floor 1) replaces per-feature accept negotiation; a page below the floor gets the existing update wall, a desktop with no bundle keeps native screens. iOS shell drops the form accessory bar and its own keyboard observers. Native session screens untouched. |
||
|
|
8f7cbad07b |
feat(mobile): name the machine after pairing (#22104)
* feat(mobile): confirm host identity after pairing * refactor(mobile): unify host descriptor state * chore(i18n): translate the last-known host descriptor label The remote-host row's "Last known" label shipped in English only. Every other locale now carries it, worded as each catalog already words "last known". * fix(mobile): make the pairing naming step safe to abandon and show the machine live Pairing: - An unreadable status.get reply no longer strands the pairing race: the descriptor read ran inside the race's success handler and threw, so the candidate was never counted and a direct-only pairing sat on "Connecting..." until the timeout. The race now reads the status through a reader that returns null instead of throwing. - A pending pairing is now a small state machine: a save in flight owns the outcome (Cancel, back, or unmount no longer clear the journal under it), a failed save stays pending and the naming screen shows the error with Save still available, and Cancel never rejects. - When the desktop refuses relay provisioning, the journal is cleared before the naming step instead of at save, so an app kill on that screen no longer blocks every later scan with "recovery pending". - A pairing that resolves after the screen went away is cancelled rather than left with its journal. - The screens keep their root ref callbacks stable (the latest pending pairing is read from a ref) instead of re-creating them per pairing. - The label field's placeholder shows the name that an empty label saves. - "Is this an existing host" is derived from the id identity resolution hands back, so host-store and its tests go back to main's shape. Machine descriptor: - Mobile keeps the host-reported machine name and OS in memory only, filled by the status reads that already happen, and labels it "Last known" from the row's own connection state. This drops the per-host AsyncStorage copy, its web sibling and web-overrides entry, and the removal cleanup. It also fixes a latch: freshness used to stay true for the whole process once a host had answered. - Desktop reads the descriptor through lastVerifiedRuntimeStatus and marks it "Last known" using the same reachability verdict as the row's dot. - Drops the unused hostname/previousPlatform resolver inputs and moves the "OS · machine" formatting into the shared resolver. Also restores main's page-only Reconnect gate in the host header (#22326), undoes the no-op toStoredHostProfile reformat, and re-measures the web session route at 4222 modules (one fewer: the dropped web persistence file). * test(mobile): re-record RPC goldens for the deferred pairing save Repins baseline to the pairing fix commit and re-records every golden. Against main, 781 goldens move only in the header (baseline everywhere, adapterSha256 for the pairing adapter family). Six pairing goldens move in the body, and only in effect order: the pairing now resolves (closing its candidate sockets) before the naming step saves the host, and a refused relay provision clears its journal before the save rather than after. The set of effects and every outcome match main. This also removes the unhandled-rejection effects and the failed result-absent/result-null cells that the previous recording captured from the pairing race's throwing status read. * fix(mobile): keep the machine name out of the host header title while the label loads The host screen starts with an empty saved label and loads it asynchronously. With a descriptor already in memory, the resolver fell back to the machine name as the title for that window, so opening "Windows-Low Spec" briefly titled the header with the Mac's name. Read the descriptor only once the label is known. * test(mobile): build the pending-pairing status through its schema so the test typechecks The mobile tests typecheck ratchet rejected a partial status literal: the reply type keeps every optional field as a required key. Parsing the literal through the status schema yields that shape without a type assertion. * test(mobile-web): re-pin the session route closure after merging main #22301 added two src/shared modules this route reaches; its own CI never ran this suite, so the merged branch read 4224 against the 4222 pin. Measured on the merge. * feat(mobile): name each host by what its desktop reports Pairing saves the host immediately again and names it after the machine name the desktop publishes; every connection refreshes that name and OS, so a rename on the desktop reaches the phone. A name typed on the phone's Edit screen is kept as a phone-local override that wins; clearing it returns to the desktop's name. - Stored host profiles gain optional personalName, lastKnownMachineName and lastKnownHostPlatform; `name` stays the resolved value older builds read. Legacy records classify a generated "Host N" as desktop-named and any other name as a phone override. - The connection layer runs one retrying status probe per connected host and records the descriptor; the capability probe becomes a projection of it. - Rows and the header always show the OS, add the machine name under a phone override that hides it, and mark it "Last known" while the host is offline. - Removes the deferred-save naming screen and the one-shot home status fetch. - Name rules move to host-name-identity.ts and the host-list mutation queue to host-list-mutation-queue.ts; an unchanged mutation no longer rewrites storage. * test(mobile): restore the RPC recordings to main's Pairing saves the host back to back again and the recording adapter is main's, so every recording matches main byte for byte; the earlier deferred-save re-record and its baseline repin no longer apply. * fix(mobile): keep stored host name identity across snapshot saves and on the web page A connection re-saves its host profile snapshot on relay credential rotation or relay re-resolution. The re-pair merge let that snapshot's name identity win, so a phone rename cleared or changed since connect came back, and a newer desktop machine name rolled back. The stored record now keeps the name identity on every save; the save supplies the rest. The web page receives only the app's resolved host name, with no identity fields, so the docked host header treated a phone rename as "no override" and titled the host with the live machine name. The display hook now reads such a source the way storage reads a legacy record: a non-generated name is the user's label. * fix(mobile): hand the web page the host's stored name identity The page received only the app's resolved host name, so it had to guess whether that name was the phone's override or the desktop's name. It guessed "override" for any non-generated name, which froze a desktop-adopted name as the title after the desktop was renamed, and left an offline page header without the last-known OS and machine name. The shell now puts the stored personalName and last-known descriptor on the init host as optional fields. The page reads them exactly as the app does; a page handed its host by an older shell still falls back to classifying the name, and an older page ignores the fields. * fix(mobile): drop an unreadable host name field, not the whole host The stored host record and the page's init host checked the platform against a closed list and required non-empty names. A value this build does not know, such as a platform added in a later build, failed the whole record: the host list dropped the paired host and the next write persisted the list without it, and the page refused its init message. Those three optional fields are now salvaged, so an unreadable value drops only that field. Also states the one exception to the stored name rule (an OS reported without a machine name keeps the adopted name), and brings four mobile test files in line with the branch: the edit screen now saves `personalName`, and host opens now start a descriptor status probe. * refactor(mobile): name the shared host name fields for their role * fix(mobile): drop the "Last known" prefix from the host machine line The OS and machine name line under a host's name reads the same whether or not the host is connected; the connection status already says when it is offline, and a prefix that users could read as applying to the name added nothing. Removes the resolver's liveness input and the desktop row's translation key. |
||
|
|
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 |
||
|
|
3f9582bc90 |
fix(mobile): removing a host deletes its page cache through the one process store (#22352)
* fix(mobile): removing a host deletes its page cache through the one process store Host removal never deleted the removed host's page generation cache, so it sat on disk until four other hosts were activated and a re-pair could reopen the old tree. Every caller also minted its own GenerationStore with its own queue, so a removal's index or update-failure-log write could drop the mounted session's write landing inside it. The store is now one per process (processGenerationStore, with a reset-for-tests seam); the shell runtime, host removal and Troubleshoot all share it, and removal deletes the host's cache after the metadata commits, fire-and-forget beside the failure forget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the cold-start store per mount is the test's, not production's Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
0e6862cbcc |
fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials Six failed-load screens share one Retry shape: re-dial a host that is not connected, otherwise re-read. On the page the re-dial is inert (`client-context.web.tsx:55`) and each screen's load already re-runs when the shell's client reconnects, so in the disconnected state that Retry did nothing at all. `connectionRetryAction` makes the decision once and answers null when a re-dial is needed and none exists; agent history, the file explorer root, the file preview, git history, the source-control status gate and the diff review render no Retry for null. The explorer's per-folder Retry keeps its control: it queues the folder, and the queue drains on the next `connected` whoever brought it back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page offers no re-dial, so no header offers one `forceReconnect` on the page was `() => Promise.resolve()`: the shell owns the connection and nothing in the document can re-dial it. The host header's Reconnect and the session header's "tap to retry" were wired to it and did nothing there. The context member is now nullable and the page's provider hands out null, so the compiler found every caller: both headers render no reconnect affordance for null, and the session status keeps the verdict label without promising a tap. Native providers and the recording adapters still pass a function, so nothing a phone renders changes. The session route's host-JSX parity hash moves for the header's extra null check; the page test doubles that stubbed the old inert re-dial now stub null. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the auth-failed banner cites the page's re-dial as null Three comments and the banner's override reason still said the page's `forceReconnect` was an inert `() => Promise.resolve()`, and cited `client-context.web.tsx` lines the previous commit moved. They now say null and point at the lines that hold it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the session closure gains the page's Retry decision `connection-retry-action.ts` is the one module the Retry fix adds to the session route's page closure, reached through the explorer, source control and git history it docks. Measured on this head with all five generators run first, and diffed against the pre-change closure: one local module added, none removed. Session route closure 4207 -> 4208 modules, local 1021 -> 1022. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the capability probe belongs on the page, and says why The push fence excluded `runtime-capability-probe.ts` because the session route and the host screen run it. The session half holds, and the probe works there: `status.get` carries no client identity and makes no write, the shell forwards it like any non-`native.` request, and the desktop's mobile allowlist admits it. The host-screen half no longer does: `codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which the bundle carries and the page hands to the native screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the session retry test's cast under its disable line The formatter wrapped the cast onto the line after the disable comment, which left it uncovered. The cast now sits on its own line directly below the SAFETY note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the agent-history Retry test mocks the pathname the handoff reads Main's page route handoff now subscribes to `usePathname` (#22300), and the Retry suite this branch added mounts that handoff with an `expo-router` mock that lacked it. Same one-line addition main made to the back-handoff suite. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the reload each hidden page Retry relies on Hiding a Retry on the page rests on the screen's load re-running when the shell's client reconnects, because nothing on the page re-dials. Only the explorer's folder drain pinned that. Each other site now has a case that starts unreachable with no Retry and asserts the load goes out on the client and state the reconnect delivers: agent history (status.get), file preview (the preview read), diff review (the snapshot load), git history (git.history) and source-control status (git.status, in the loaders suite because the panel test mocks the state hook). Each goes red when the `client`/`connState` dependencies it guards are removed; for source control that is both `loadStatus` and the `loadBranchCompare` it depends on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): one import of the transport types in the source-control loaders test CI's native code-quality audit denies the duplicate-import warning the reload pin added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
7240368726 |
feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived
The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): record why a hybrid shell update failed, on the device
A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): forget a removed host's recorded update failures
Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): show recorded update failures in Troubleshoot
A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the update-failure row doubles without casts
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forget a host's update failures once a newer generation commits
The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-pin the session closure for the shared journal producer #22299 added
main at
|
||
|
|
11db2b9a7d |
feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key The shell's page had no way to hear Android Back: every sheet inside it early-returned on web, so the key popped the whole session route. Adds the first negotiated shell-to-page frame kind alongside it. - `back-claim`, page to shell, declared in `init.accepts`: the document is holding the key, or has let it go. - `back`, shell to page, declared in `ready.accepts`: one press, dispatched to the newest consumer that takes it. A press nothing takes is handed back as a `navigate-back` rather than dropped. Both are optional fields on frames the other side already reads, so an old shell never hears a claim and an old page is never sent a press; each pops as it does today. No protocol bump and no stream opcode. `bridge-host.ts` was at its line cap, so the notify forwarder moves to `bridge-host-notify.ts` unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): Android Back closes the sheet on the page, not the screen Inside the shell's page every sheet early-returned on web, so one press left the session route with the sheet still open. The drawer, the right drawer and the file-preview prompt now claim the key through one seam on both platforms: `use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on the shell's. All sixteen session sheets render through `MountedBottomDrawer`, so the one claim there covers every one of them, and a census fails if a sheet bypasses it. `route-handoff.web.ts` claims while the page grew a stack of its own, and hands the press back when it did not. The shell takes the key off the navigator only while a claim is live: Android gets a `hardwareBackPress` handler that returns the host's own answer, iOS loses the stack's swipe-back. The claim is cleared on `document-started`, on a remount, on a new `ready`, on anything that takes the generation off screen, on the page's `close` and on dispose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a Back press closes a sheet on the real bundle The unit suites reach both halves of the lane but never the two together on a document a browser rendered. The render rig can now post a `back` frame, and the drawer check opens the Filter sheet, reads the claim off the notify list, sends one press and pins that the sheet closed with no `navigate-back` behind it. Red without the drawer's claim: the claim never arrives. Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is shared with the native app and mounts under no page provider in a bare tree, where `usePageBridgeClient` threw; the seam now reads the bridge through `usePageBridgeClientIfPresent` and claims nothing without one. Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route handoff, `bridge-page-back.ts` through the envelope. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): mirror the Back seam's latest values from an effect Three `ref.current = …` writes sat in render, which React replays and discards. Each moves into a dependency-list-free effect declared ahead of the registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already uses for the same reason: the caller rebuilds the value every render, so there is nothing to depend on, and `useRef` seeds the first mount. The registration still keys on the claim alone, so a rebuilt handler re-registers nothing. The web seam's test drops its two type assertions for a named fixture type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page says its Back claim again on every init The claim was edge-triggered and the shell forgets on purpose: it drops the claim answering every `ready`, and a host rebuilt under a live page — a client swap through forceReconnect, which leaves the WebView mounted — starts with none at all. A document still holding a sheet was then unknown to the shell, and the next press popped the screen out from under it. `init` is the shell saying it is here now, so the page answers each one with the state rather than with a transition. Posted after the session has taken the frame, so the gate reads that `init`'s own `accepts` and a shell that never named the claim still hears nothing. Nothing is said while nothing is held. Every `init` answering a `ready` comes from a host that dropped the claim first, so it already holds false; the only other one carries a rewritten route, where a stale true needs a `false` the page posted to have never left, and a port that refused that frame refuses this one too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a rebuilt host keeps the session's Back claim A host is rebuilt when the client under it changes, and the page document does not move: the WebView stays mounted, the session id holds, and the page is never told. The rebuilt host started with no claim and no `accepts`, so it refused every press and the navigator popped the screen out from under an open sheet. Two clients on the same generation leave the page nothing to refuse, so nothing made it re-ask and re-assert. What the page declared and what it is holding are facts about the session, the way `sessionEstablished` already is. `createBridgeHostBack` takes them as a seed, `readSessionBack()` hands them on, and the hook holds them stamped with the session so a record left by one never seeds the next. `dispose()` no longer reports the claim gone: a host retiring is not a document ending, and that report was the thing taking the key off a live sheet. Every reset path is unchanged and still has its own case — the page's `ready`, its `close`, and the session's own store for `document-started`, `remounted` and anything that takes the generation off screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3bb9a4e261 |
fix(mobile): keep a painted frame under the page until its first paint (#22264)
* fix(mobile): keep a painted frame under the page until its first paint The shell tore its own frame down the moment a generation was on screen (`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws nothing until its document paints. What showed for the whole of the page's boot was the surface behind it with nothing on it: 1.42 s on a cached generation, against a one-frame budget. The page is the only thing that knows when it has a frame, so it says so. It declares `painted` in `ready.reports` and posts the notify after the browser has painted its first commit; the shell holds the same neutral frame it was already painting while it opened the generation, then fades it out. The wait is bounded by the declaration and never by a timer: a generation served by an older desktop declares nothing and is uncovered on `ready`, which is what every shell did before this. iOS painted white rather than nothing: a WKWebView is opaque by default, so the shell's own surface never showed through. It is now transparent, as the Android view already was. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the cover through the compositor handover The page reports the paint its own renderer made; putting that on the app's surface costs another frame or two. A linear fade from the report left two frames of bare surface between the two on an emulator, which is the hole the cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity across that handover: five reopens now show 0-21 ms of bare surface against 102-2043 ms on the build without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): negotiate the paint report in both directions The page posted `painted` whatever shell it met, and `notify` is a closed union: every shell installed before this answered it with an error frame, once per mount. The shell now advertises the name in `init.accepts` beside the param clear and the client identity, and the page posts only when it was advertised. The declaration in `ready.reports` stays unconditional, because it is an optional field an older reader strips rather than a new opcode, and because the first `ready` — the only one that matters for the first paint — is sent before any `init` has arrived. The accepts list moves into `bridge-init-frame.ts` beside the grants, which is the module that builds the frame carrying it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cover's colour instead of asserting its shape Two gate findings on the round-two head. The cover test reached the background through a cast of the style prop; it now reads it through a checked narrowing, so the test proves the shape it depends on rather than declaring it. `use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args gained `onPagePainted`: its harness is a literal, so a new required handler is a missing property. The probe now counts paints and one case spends the counter, which is what a handler wired only to satisfy a type would not do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the cached-generation opening out of the reducer `mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused- update work and the paint handling both grew it. What comes out is one thing — putting a generation already on disk on screen, and deciding whether this route is one that bundle carries. It is the reducer's cache path and its refused- update path both, and it was already three functions sitting together. `step` goes into a module of its own because the two now share it; a copy in each would be two spellings of one transition, and exporting it from either would point the dependency the wrong way. No behaviour moves: the reducer's table tests are unchanged and the page closure is unchanged at 4,211, since neither new module is reachable from a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the previous document's paint when a new one starts A document that replaced a painted one inside the same mount inherited its `pagePainted`, so the cover lifted before the replacement had drawn anything. The native view already reports `loading`; the screen dropped it. It now reaches the reducer as `document-started` and clears the page document state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the declaration case call the frame policy The case compared the name to itself and never called `shellPageFrame`, so it passed for a policy that ignored the declaration entirely. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the page's frame from the route screen, not the router Every route screen is behind `import()`, so the wrapper above expo-router commits with a suspense fallback while the chunk is still arriving. The paint report hung there, which uncovered the shell's view over an empty body on a cold chunk. It now hangs on the screen the manifest resolves, layouts excluded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): retire the readiness wait a replaced document armed `document-started` cleared the page document state and left the flow alone, so the previous document's readiness deadline passed the flow check, read `pageReady` as false and failed a session whose replacement was still loading. The flow moves with the document, for the reason `remounted` already moves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a departing route screen take its paint report back The report waits two frames, and nothing cancelled the second one, so a screen unmounted in between still told the shell to uncover. The reporter now answers with a take-back the wrapper returns as its cleanup, and the once-per-document latch frees only when a report was cancelled before it landed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the screen that arrived take over a frame still owed A screen committing inside the two frames an earlier one was owed found the latch taken and reported nothing; the earlier screen then freed that latch on its way out and nobody was left to lift the cover. The newest commit now supersedes the pending report, and only a posted one spends the latch. Covers the redirect window with a render check against the pr route, whose target chunk is held open while the document sits on the hub's fallback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the take-over case turn on the take-over The case cancelled the first screen's frame through the cleanup path, so it passed with the take-over deleted. It now leaves that screen mounted and reads the clock: the frame after the replacement commits is the replacement's first, not the one the screen behind it was still owed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
66ade30576 |
fix(mobile): the page's live input stays above the soft keyboard (#22252)
* fix(mobile): the page's live input stays above the soft keyboard Edge-to-edge makes the manifest's `adjustResize` inert, so the window never shrinks for the IME and the page's `visualViewport` reads full height with the keyboard up: the session route laid its live input row out under the keys. The shell owns the window, so it shortens the WebView by the keyboard instead. The session screen's own `Keyboard.addListener` pair never fired on react-native-web, so the page also never held off the terminal refit. Both facts now come from the platform seam, which answers them separately on the page: the keyboard is open, and it covers nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the session keyboard harness without a cast The hoisted keyboard now carries the seam's own `SoftKeyboardState`, and the mocks the screen is handed carry the types it calls them with, so nothing is asserted into shape. The scope the harness builds could not be spelled at all before: the hook took the whole lifecycle model to read 28 of its fields. It now names those fields, which the model still satisfies, and the test builds one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3d76c22b57 |
fix(mobile): a refused update serves the cached generation while the host is up (#22237)
* fix(mobile): a refused update serves the cached generation while the host is up A newer generation that fails to fetch or stage was refused with a named reason and then painted a wall with "Try again" over an intact generation already on disk — the same one the offline branch opens without being asked the moment the host goes away. `onDownloadFailed` now branches on what is cached rather than on which side refused: with nothing on disk the refusal is still the screen, and with a generation on disk it is opened through the offline branch, judged by its own route list. The refused generation is never staged, committed or persisted, and nothing about the refusal is written, so the next launch asks the host again. The bundle-side refusal is named as a dismissible notice above the page, on the existing host-route banner. It says what happened and promises no retry, because the shell schedules none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): judge the cached generation against the host it can reach before serving it The fallback served a cached generation on the strength of the offline rule, which skips the compat check because a host nobody can reach cannot have changed. On this path the host has just answered, and an update usually exists precisely because it moved — so bytes that were inside the protocol window when they were written may be outside it now. `CachedGeneration` now carries the three fields the compat verdict reads, projected in `openCache` off the manifest stored beside the assets. That manifest is never absent: `readActiveGeneration` answers null for a generation whose manifest did not parse, and the read schema requires all three. `cachedGenerationWall` lives beside `gateVerdict`, because only an `open` gate is judged further. The other verdicts already have answers there: an absent capability is the native-route rule, and an unreadable status leaves the same empty list, so walling on either would be the `bundle-unavailable` wall that file exists to keep off a host that simply did not reply. A generation outside the window now earns the wall with its verdict, not the download-failed screen, and nothing is deleted: the bytes are intact and a newer host is not what makes them wrong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ask the cached generation's own routes before walling it The compat wall ran before the route question, so a cached generation that does not carry this route — or predates route listing entirely, `routes: undefined` — earned a terminal `bundle-incompatible` wall where the answer is `native-route`. `onManifestRead` has always taken the other order: a route that stays native has nothing to wall about. `openByOwnRoutes` now asks the route first and applies the wall only on the served branch, and the update notice moved to `served`, so a native answer carries no notice about a screen it is not showing. The other half is the verdict the gates hold at the moment of the refusal. A `fetching` session does not await the gates, so a refusal can land under a verdict the flow never started on. `gateState` is now the one mapping from a gate verdict to a screen, shared by the entry into the flow and by the fallback, so the two cannot answer the same verdict differently: a host that stopped serving a bundle is `native-route`, a status that went unreadable says so and re-arms, a dial in progress or a pending status waits in `checking`, an unreachable host keeps the offline rule and serves the cache unjudged, and `open` is the only answer that leaves a host to judge the generation against. That inverts two round-2 assertions that expected the cached page to be served when the capability list had gone empty. Both were wrong for the same reason: an empty list is the gate's question, not a verdict about a bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): announce the host route notice banner, as loudly as its tone The banner is inserted into a screen that is already on screen, so a reader who has moved past the top of the list never arrives at it. It carried no live region and no role, so nothing carried it to them. The urgency follows `tone` rather than being assertive for everything. The failure tone is an action that did not happen — a refused worktree action, or the shell's refused update — and interrupts with `alert` and an assertive region. The notice tone is a bounced route, context for a list already being read, and waits its turn politely; interrupting for that would train people to ignore the first. No role on that arm: React Native has no `status` role, so the polite region is the whole of the answer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
59c0d5585e |
fix(mobile): the shell swaps the page's client identity so its terminal reaches init (#22201)
* fix(mobile): give the page a client identity so its terminal reaches init
The page's RPC provider answered `getClientId` with `null`, and the session
route refuses `terminal.subscribe` without a client identity. No subscribe
meant no scrollback, so `init` never reached the terminal document: the
surface stayed 0x0 and the document answered every measure with `notReady`.
`canSend` reads the same value, so live input was dead for the same reason.
The identity is the page's shell session, not the pairing credential `init`
deliberately withholds: the host only ever uses `client.id` as an opaque
in-memory key for the mobile input floor and the viewport claim.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): the shell swaps the page's client id for the device's
The host does not read `client.id` as an opaque key: `terminal.send` refuses a
query reply whose id is not the credential the socket authenticated with, and
`terminal-send.test.ts` pins that as a spoof. So a page-chosen id was wrong on
that path however stable it was, and the session id
|
||
|
|
da1c322b00 |
feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in preferences.ts. Expo's babel preset inlines a literal process.env member expression at build time, so a release bundle carries the answer as a constant and anything but the exact string 'ota' — unset, empty, a typo — is native. Every default build is therefore the native app, unchanged. mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to mount the page comes from the build and never from storage: a native binary installed over an OTA one, same bundle id and same data container, still refuses a stored 'true' without reading the key. An unset key reads on only in an OTA build; a development build keeps its opt-in, and a stored 'false' wins everywhere so the Troubleshoot toggle can switch an OTA build back to native. That toggle now mounts wherever the flag can be on, which is the only way back to the native screens in an OTA build, and its label names the build kind rather than saying "(dev)". The bundle probe row beside it stays development-only: it fetches. The flag census gains two rules — one module reads the switch, in the member form Expo inlines and not the bracket form, and one named function answers the build kind — and the build-kind fence now lists the Troubleshoot route that asks it. Docblocks that said a store build can never mount the shell now say it mounts only when built for OTA. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): one workflow input picks the shell, and no input means native Both release workflows gain a `shell` workflow_dispatch choice, options native and ota, default native, and hand it to the step that bundles the JavaScript as EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and the fastlane build_and_upload step on iOS; nothing else in either file sets it. A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'` yields native for them — the first OTA release is a dispatch with one field changed, and every other run is the app we ship today. Each build step prints the value it is about to build with, read back from the same variable rather than from a second copy of the expression, so a run's log cannot claim a shell the build did not use. The new contract test evaluates that expression rather than matching its text: absent, empty and 'native' all resolve to native, 'ota' to ota, and any expression shape it cannot evaluate is a failure rather than a pass. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build: the desktop packages the real page, and the placeholder is retired build:mobile-web now runs the app builder and app verifier, and both take their output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each carrying a constant of their own — one definition of where the bundle lives, so a drift cannot leave electron-builder's beforePack looking at an empty directory while the builder reports a tree it wrote elsewhere. build:mobile-web:app is gone; it was the same two commands. src/mobile-web/ and its two scripts go with it. What the app builder shared with them is split into three modules named for what they hold rather than for the bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the canonical asset serialization, buildId, hashed assets, the protocol window and the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two failure modes are Windows paths and symlinked entries), and mobile-web-source-line-endings.mjs (the CRLF guard, now with a required directory rather than a default pointing at the deleted tree). The two suites that only needed *a* valid tree on disk — the beforePack guard and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree instead of bundling the whole mobile graph. It goes through the same manifest writer the page does, so a manifest shape change still reaches them. Also retired: the placeholder's tsconfig project and its typecheck lane, its knip entry, its electron-builder exclusion and .gitattributes pins, and the app-bundle test that asserted the shims stayed out of a builder that no longer exists. pr.yml's page job builds the same bundle the package job ships. Inert for native phones: they never fetch it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(config): one import of node:fs/promises in the entry-detection suite The changed-code quality gate's focused plugins read the two as a duplicate import; the readFile line was left over from the split. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs: the comments that still describe the retired placeholder bundle The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app and shipped by nothing. That script, that directory and that fact are all gone: it is built by `build:mobile-web` into the packaged bundle dir, and a phone mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota. Two Windows cache keys explained themselves by naming src/mobile-web and "the two bundle builders"; config/** now covers the builder, the verifier and the manifest writer, and the spike's key no longer waits on a Phase C flip that has happened. The keys themselves are unchanged. Three scratch directories in the app-bundle suites and one in the verifier still spelled the retired output root. Renamed to mobile-web, which is what the build writes; they are temp subdirectory names and nothing reads them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
faf7567df5 |
fix(mobile): the catch-all carries a deep link's query, and reads a trailing rest-segment pattern (OTA phase D, C8 carry-forwards) (#22180)
* fix(mobile): carry a deep link's query through the catch-all switch The catch-all built its pathname from the `page` segments and dropped everything else, so /h/x/session/y?tab=files reached the page as /h/x/session/y. Every named switch passes its params explicitly; this one cannot name the keys, so it takes everything useLocalSearchParams merged in that is not one of the two segments the pathname is built from. firstParam per key, as the named switches do, because init.route.params is one value per name and a repeated key would cross as `a,b`. An empty value is kept: a named switch drops its own because it knows what its screen makes of one, and this switch knows no screen. Over the schema's param ceiling shellScreenRoute answers null and the switch refuses, which is the same fail-closed answer it gives a pathname the bridge refuses. Red first: five of the seven new cases fail on the old body — the carry, the empty value, the repeated key, the encoding round trip through shellScreenRouteKey, and the ceiling refusal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read a manifest route pattern with a trailing rest segment matchesRoutePattern was segment-count exact, so `/h/[hostId]/[...page]` read as an ordinary dynamic segment and matched exactly one segment. Nothing on the desktop declares such a pattern today; a desktop that ships a catch-all screen later writes one into its manifest, and a phone already in the store has to read it, which is what OTA is for. A trailing rest segment now matches one segment or many, never zero, and never an empty one. One or more and not zero is expo-router 55.0.18's own answer for the same pattern: getReactNavigationConfig.js turns [...page] into *page, fork/getStateFromPath-forks.js turns that into ((.*\/)), and cleanPath beside it gives every path a trailing slash, so the tail must hold a slash of its own. Measured against both functions directly. A rest segment anywhere but last, or a second one, is refused by name (rest-segment-not-last / rest-segments-repeated) and matches nothing, so such a route stays native rather than being guessed at. grantsForRoute now prefers an exactly declared route over a rest one for the same pathname: a rest pattern covers everything under its prefix, and first-declaration-wins would hand a screen with its own row whatever the catch-all asked for. No schema change. Both manifest readers already accept the pattern string -- the host's ROUTE_PATHNAME_PATTERN allows brackets and dots, and the phone's is a bounded string -- and that is pinned rather than assumed. Red first: seven of the twelve new cases fail on the old matcher. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the one place the rest-segment rule narrows expo-router Its own matcher takes a non-trailing rest — h/*page/tail matches /h/a/b/tail at 55.0.18 — and this refuses it. Written where the refusal is, so the divergence is read when it changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the fragment key from the catch-all route's query expo-router's parseQueryParams seeds params['#'] from the URL fragment before it reads the search string, so /h/x/session/y#files reached the switch with a `#` key and crossed as params: { '#': 'files' }. A fragment is not a query and the route the page is given carries none. Red first: both new cases fail on the old filter — the fragment-only link lands a params key at all, and the mixed one carries '#' beside `tab`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ed909846f6 |
test(mobile): an offline cold start reads the grants of the last manifest accepted (OTA phase D, finding 7) (#22175)
* refactor(mobile): share the generation store's in-memory disk The fake answers the file-system port the store is written against, and the offline-grant suite drives the same real store against it. Two in-memory trees answering one port differently is how a passing suite stops being evidence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): an offline cold start reads the grants of the last manifest accepted Finding 7 on #21503 end to end: the reducer, the runner, the real generation store and a disk, with only the network and the platform modules doubled. A grant-only edit keeps the bundle's build id, so the cached assets are right under a manifest an edit behind; the verdict measured here belongs to a second mount whose whole evidence is the tree on disk. Both directions of the edit and both arms: a widened grant list reaches the offline verdict, a revoked one is gone from it, and a manifest read under a wall is not written, so offline keeps the last grants this shell could use. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the extracted fake's type at its import The store suite still spelled the local type name. `tsconfig.json` excludes test files, so only the tests-typecheck ratchet sees this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
895f2cf477 |
test(mobile): the web app's script fence is re-derived from a measured sweep (OTA phase C, C7.8) (#22152)
* fix(mobile): re-derive the web app script fence from the measured spread (OTA phase C, C7.8) `4r + 16` was a guess at break-even, and its slack ran from 17 scripts at one route to 4 at thirteen -- loosest where nothing is and tightest where the tree actually sits. Re-measured by building every prefix of the sorted route key list: 15 routes emit 67 scripts, and the marginal cost of a route runs 1 to 9 depending on what it shares, so no line through the route count is both an upper bound and a budget. The sweep is now the fence's only input. The envelope is the measurement plus one margin at the swept tree, growing by the worst route the sweep saw for every route past it, so a new route breaches it only by costing more than any route measured. The margin is four, which is the most the count has been seen to move at a fixed route count with no route added: the head that wrote the old fence read 32, 43, 61 and 69 at 8, 10, 12 and 14 routes where this one reads 34, 44, 57 and 65. Two-sided in the test, which is what stops the next bump: a build more than the margin under the envelope fails there too, so the fence has to be re-measured rather than raised. The route count stays the only term and the mermaid control still tells one artifact from 172 chunks. The shell-fit crossing comes in from 50 routes to 31 with it, because the envelope grants the worst swept route where `4r + 16` granted four. Byte budgets re-read from this build and unchanged: 8,053,438 of 9,437,184 total, 1,612,006 before the first route of the 3 MiB allowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): spell the session route's grant counts off the table (OTA phase C, C7.8) #22072 deleted `native.wakelock.set` from the session route and left both prose counts behind: "Fourteen grants" for a list of thirteen, and "the four audio verbs" for the three that remain. The all-or-none reason went stale with them -- it argued from a screen free to lock, which is the verb that was removed, and the device side has owned that lock since. It now argues from the microphone a route granted two of the three cannot close. The census beside the route-declaration test is what stops the next one. It reads which number words appear before "grants", "audio verbs", "media verbs" and "or none" anywhere in the file and compares them with the list itself, so a second spelling left in place fails rather than passing on the first correct hit, and a comment rewrapped at a different column still matches. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the four sibling comments spell their grant counts off the tables too (round 1) #22072 removed `native.wakelock.set` from the session route and left the count standing in four more files than the route table: "fourteen grants" in the call-site census and in the hop census, "the four audio grants" in the census and its test, and "the four audio verbs" on the bridge schemas. Each is now counted off the table it describes rather than copied beside it. The six-versus-eight split in the call-site census could not be re-derived -- its own "those eight" never summed to the six rows plus the audio grants, so it was wrong before #22072 too. It is replaced by what the tables say today: six rows pin eight of the session route's thirteen grants, and the other five have censuses of their own. Both numbers come from `PAGE_GRANT_CALL_SITES` and the route list, and the existing case that names six rows covering eight grants is what holds them. The census the route table got is now `spelled-count-census.mjs`, driven from three files instead of one. A phrase restated in a file collapses to one claim, so a header and a test name may spell the same count; a phrase with no number before it reads as the empty list, which no table count matches. The bridge test is the one site with no count left to pin: it names `BRIDGE_NATIVE_VERB_NAMES` instead, and a new assertion holds its own `AUDIO_VERBS` equal to that table's audio rows, so the cases below cannot pin one set while reading as coverage of another. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the session sentence off the session intersection (round 2) `'of the session'` was counted off every grant in `PAGE_GRANT_CALL_SITES`, which is not what the sentence it pins is about. A row pinning a grant no route declares -- the shell can serve a verb before a screen asks for it -- would have made the census demand the comment overstate what the session route has. Proved before fixing: adding `native.share.send` to the navigate row moved `pinnedHere` from eight to nine while the session intersection stayed at eight, and the census failed asking for "nine of the session route's thirteen grants" against a sentence that was right to say eight. With the intersection it passes under the same probe, and the failure moves to `'grants this file pins'`, which is about this file's rows and does correctly demand nine. The other three rows are left as they were, for the same reason read the other way: `'grants this file pins'` and `'did not'` are about the rows here, so they keep the full list, and `'have censuses of their own'` and `'are not repeated here'` already count the session grants no row pins. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the two counts in this file's it titles too (round 3) The census pinned the JSDoc counts and stopped there, so "names six rows covering eight grants" and "reaches every one of the eight through the session route" were free to go stale. Shown rather than argued: with a ninth grant on a row and the header corrected to nine the way the old census forced, the suite went green with both titles still saying eight. `'grants this file pins'` becomes `'grants'`, which reads the header and that title as one claim -- they are the same number, and a row per site would have let them disagree while both passed. The session title takes the intersection, for the reason round 2 gave. Every other spelled number in the file is not a count of a table: "the two tables" is how many sources the census reads, and "the one it reads", "a new one cannot be missed", "any one grant went missing" and "every one of" are quantifiers with no table behind them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the session-route title off the rows it asserts over (round 4) `'through the session route'` took the intersection, but the title it pins heads an assertion that compares `grantsNeeded` with every row's grants. The title names the session route and is not a claim about it: it says the rows here are all reached through that route, so it moves when the rows move. In the divergence the JSDoc already described, the two parted. With a ninth grant on a row the assertion compares nine while the census held the title at eight and passed, leaving a title reading below the assertion under it. Exactly one row takes the intersection now, the `.mjs` sentence for how many of the session route's grants these rows cover, and the JSDoc says so rather than describing a rule with two members. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): two wording fixes, and the byte readings re-measured on the merge (round 5) pullfrog on the census JSDoc, both correct. "make the census demand overstates" was a finite verb where the bare infinitive belongs. "Every other row counts the rows here" was contradicted by its own table: the rows reading the route's list and the rows counting grants no row pins take neither count, so the sentence is scoped to the rows that choose between the two and says what the rest read. The two byte readings in the fence doc are re-measured on the merged tree, since they name a head: 8,055,568 of 9,437,184 total and 1,612,253 before the first route. C8.1 added no route, so the sweep and the envelope are untouched and the tree still builds 15 routes into 67 scripts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e47ef8cc28 |
feat(mobile): the shell tells a page which optional capabilities it has (OTA phase D, C8.1) (#22141)
* fix(mobile): publish page-route pairs the strict host schema accepts (OTA phase D, C8.1) `routeViewOf` handed the manifest's own route entries to the host as `pageRouteGrants`. The phone reads a manifest route loosely, so an entry arrives carrying whatever field the desktop that wrote it knew about, and `BridgePageRouteGrantsSchema` is `.strict()`: one unread key refuses the pairs, `createBridgeHost` refuses the route with them, and the page gets no `init` at all rather than losing one field. Fixed before any route carries an optional grant (ruling 37.4), so the manifest field the next commits add costs an installed shell nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: drop the closure and bundle probe scripts from the tree Scratch measurements for C8.1 (which route closures reach the HTML preview, and what the preview render rig costs to bundle with a client provider). They belong outside the repository and were swept in by the previous commit's `git add -A`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): a manifest route may declare optional grants (OTA phase D, C8.1) Design B of design-ota-c8-1.md, ruling 37. `MobileWebBundleRouteSchema` grows `optionalGrants` under the required lane's own grammar, with the 16-name ceiling applied over the union of the two lists rather than to each. Serving a route still reads `grants` alone, so a capability a screen cannot work without stays required and takes the route native; a session's granted list is `[...grants, ...optionalGrants]` narrowed to what this shell implements, from one helper that both `grantsForRoute` and the `pageRouteGrants` publish read. The ruling's compatibility rationale is corrected in place. `z.looseObject` passes unknown members through rather than dropping them (measured, zod 4.4.3), so a shell older than the field still receives the key; what it lacks is a policy that reads one. What makes the lane safe against such a shell is therefore the previous commit's publish fix, not the reader. BRIDGE_PROTOCOL_VERSION stays 1. No new notify, verb or frame field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name the shell's cancelled-navigation behaviour as a grant (OTA phase D, C8.1) `externalNavigation` joins `MOBILE_WEB_SHELL_GRANTS` beside `screencastBinary` and `haptics`, declared in `cancelled-navigation-target.ts` because that is the module holding the rule which acts on it. A third token that is neither a verb nor a notify: the page posts nothing to make a cancelled top-frame navigation happen, so this list is the only thing that can tell a page whether a tap inside the sealed HTML-preview frame escapes at all. A constant and not a platform read (ruling 37.1): both engines dispatch the event, `ios/MobileWebShellView.swift:481` and Android's `MobileWebShellView.kt:382`, so an app build carries the behaviour on both or on neither. The policy census grows the half that was only pinned by the verb table: the implemented set is that table plus exactly three non-verb tokens, each read off the module that declares it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the bundle builder carries a route's optional grants (OTA phase D, C8.1) `resolveMobileWebPageRoutes` maps each declaration member by member, so a field the declaration grows reaches a phone only once the map names it: until now `optionalGrants` would have been dropped in silence and every route would have declared nothing optional. Omitted when the route declares none, because absent and empty are the same answer to a shell. The declaration suite grows the rule rather than a row: the map carries the lane through and writes no key without one, and the lane is held to the manifest's own grammar and to the ceiling over the union of the two lists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the HTML preview hides its links on a shell that cannot open one (OTA phase D, C8.1) The session route declares `externalNavigation` on the optional lane, and the preview asks for it before it renders an artifact's links as links. Ruling 37.2's three readings are what "hide" means here, and removing `href` is what delivers all three at once: `a:any-link` stops matching, so the UA stylesheet stops underlining, the element leaves the tab order, and there is no dead anchor a tap does nothing on. The text the author wrote stays where it was, the artifact paints, and the Preview/Source toggle is untouched. Done with the browser's own parser rather than over the source text: an `href` inside a comment or a `<template>` is text to a browser, and a pass that rewrote either would be editing the artifact instead of its links. The frame also loses `allow-top-navigation-by-user-activation` on that path, so a link the pass somehow missed is refused by the browsing context as well. One route, measured rather than assumed: the design said two, and the file preview route's closure does not reach the HTML preview at all - it renders `MobileFilePreviewScreen`. The new closure census derives that list from the hook's callers. The render rig grows the case on both engines and the readings it needs, and `mobile-web-app-preview-frame-readings.mjs` is split out of it at the readings/arms boundary, because the two were over the 600-line cap together. Two engine findings are recorded in the rig: an `<a>` with no `href` still answers `tabIndex` 0 on both, so focusability is asked by focusing; and WebKit computes `cursor: auto` for a real link, so that reading is pinned where it discriminates and its blindness pinned where it does not. The hop-coverage census now reads the effective set, because that is what the running rule compares. Inert today: the session route is the only declarer and an opener into every other route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's hidden link path where the unit suite can reach it (OTA phase D, C8.1) The mobile suite runs in a `node` environment whose resolver has no `.web` precedence, so `MobileHtmlPreview.web.tsx`'s import of the grant hook lands on the native sibling, which answers yes unconditionally. That is why the existing component suite still measured the granted frame without knowing a grant exists, and it means the hidden path had no coverage in the sharded `test` job, where the render rig is skipped for want of the bundler's dependencies. So the wiring gets its own file with the module replaced: that the component asks, and that both the frame's sandbox and the document it is handed follow the one answer. happy-dom rather than the suite default, because the inerting pass parses with the browser's own `DOMParser`. `String(node.type)` rather than a literal comparison: `node.type` is `ElementType`, which overlaps a real intrinsic tag and not the host strings these mocks render, so `=== 'Pressable'` is a no-overlap error under `tsconfig.test.json` and the tests-typecheck ratchet reds on it. Also replaces a `Reflect.get` the anti-slop gate refuses with an `in` check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session page closure at 4,362 for C8.1's three modules Measured on both sides with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `841d06a969` with all five postinstall generators run first, and the two `local` lists diffed rather than the total inferred: 4,359 -> 4,362 modules, 1,017 -> 1,020 local. All three are local source modules and none is vendored: the page's read of `init.grants.native`, the pass that turns an artifact's links back into text without the grant, and the module declaring the token beside the rule that acts on it - reached both by that hook and by `page-route-policy.ts`. The `bridge-caps.ts` it imports was already in this closure, and the hook's native sibling is replaced rather than joined. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): allowlist the preview's grant sibling among the .web.* overrides `mobile-web-app-web-overrides.test.mjs` pins the allowlist against the `.web.*` files on disk, so a new web sibling reds it until the file says why the page needs one. Red before: `expected [ …(36) ] to deeply equal [ …(37) ]`, naming `src/components/use-html-preview-link-grant.web.ts`. The preview's own entry is corrected with it: its reason said `allow-top-navigation-by-user-activation` is granted, and that token is now conditional on the shell answering that it can open such a navigation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the hidden-link render case waits on the frame's own reading (round 1) CI's chromium arm timed out at the full 240 s on this case alone while the WebKit sibling passed in 1.5 s and it passed 26/26 locally. The cause is the third arm: it tapped the granted link and waited through `expectNavigation: 'main-frame'`, and `waitForRecordedNavigation` has no bound but the case's own timeout. Under CI load the click missed its 2 s actionability window, no navigation was ever recorded, and the arm sat in that wait until vitest gave up - `recorded []`, with the frame attached only at 38.9 s. Three arms sharing one budget is what made this the case to find it. The arm is dropped rather than its wait lengthened or retried. Every verdict left is a reading the frame itself publishes: the anchors its document holds, the style the engine computed for one, whether focus lands on it, and now whether the tap this arm made landed at all - `actError` is asserted null, so a click that never reached its target is no longer the same three zeros as a tap that did nothing. Nothing is lost. The tap's outcome on a granted shell is the next case, on these same counters from this same rig and with a budget of its own, which is the presence precondition this file already uses elsewhere for the same reason. The WebKit sibling's discriminating reads are untouched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the inert-link pass changes nothing an engine renders but the links (round 2) Round 2's ruling: the hidden-link path may change nothing about the artifact's rendering except that links are not links. A parse and a reserialise is not free of that by default, and all four findings reproduced on Chromium 147 and WebKit 26.4. A same-document fragment link is kept. It starts no navigation at all, so it goes on working inside the sealed frame whatever the shell can do, and taking it away would be degradation over a capability it never needed - an artifact's own table of contents is the case. Its `target` still goes, because a fragment aimed at another frame is a navigation rather than a scroll, and `href=""` is not a fragment: it resolves to the frame's own URL. Links inside `template.content` are reached, recursively. `<template shadowrootmode>` is a declarative shadow root the frame's parser attaches and renders, and `querySelectorAll` does not walk into template content, so those links arrived live inside a sandbox that refuses their navigation - the dead anchor ruling 37.2 forbids. Measured: `parseFromString` attaches no such root on either engine or in happy-dom, so the pass can reach them. The leading newline of a `pre`, `listing` or `textarea` is written back. A parser drops one after the start tag and the serialiser is specified to put it back; measured, neither engine's does, so a round trip lost a blank line from every such block. The doctype is carried whole, and the reason is corrected from the one the finding gave. It cannot move this frame between layout modes: a `srcdoc` document takes its mode from its embedder, and measured, a quirks doctype, the bare name and no doctype at all all read `CSS1Compat` inside the frame. What rewriting it does is change the document the author wrote for no reason, with `document.doctype` observable beside a Source tab showing the original. The render case pins `compatMode` as the blind reading it is and reads the frame's own doctype identifiers as the one that discriminates. Option B was not available: the frame has no `allow-scripts` and inherits `script-src 'self'`, so nothing runs inside it and there is no injection to carry the work. Also drops a vacuous half of the affordance test. `renderSource()` is called with no argument, so the markup a Source view shows is the caller's own closure and asserting it equals the fixture passed whatever the component did. What the component decides is whether the rewritten frame stays mounted underneath, and that is what is read now. `mobile-web-app-preview-arm-driver.mjs` is split out of the render rig at the boundary the readings module already names - the rig holds what each case claims, the driver how an arm is driven, the readings what it reports - since the three were over the 600-line cap together. No max-lines disable or bump. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a fragment link is a frame navigation in this preview, so it is inerted too (round 3) pullfrog is right, reproduced on both engines before believing it. Round 2 kept `#`-prefixed hrefs on the theory that they are same-document scrolls. In this frame they are not: the document's URL is `about:srcdoc` while its base URL is inherited from the embedder, so `#section` resolves against the shell's own URL and the destination differs from the document's by more than a fragment - which makes activating it a frame navigation, and the shipped `frame-src 'none'` refuses it. Measured under the shipped policy, one tap, with something to scroll: Chromium 147 scrollY 0, frame becomes chrome-error://chromewebdata/, artifact gone, embedder reports frame-src <origin>/preview WebKit 26.4 scrollY 0, frame stays about:srcdoc and intact, same report So the destruction is Chromium-only but the absence of a scroll is not: there was no working affordance to carve out for, and the carve-out left a live link that destroys the preview - worse than the inert text it was meant to avoid. Both sandbox values behave the same, so this is the base URL and the policy rather than the sandbox. The same tap does the same thing on the granted path, where this pass does not run, so an artifact's internal links have never worked in the preview. That is not this change's to fix; it is recorded in `followup-html-preview-fragment-links.md`, and the render case reads the granted arm's violation as its presence precondition so the behaviour is pinned rather than merely known. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5064469687 |
fix(mobile): a same-build cache hit persists the fresh manifest (OTA phase D1) (#22139)
* fix(mobile): persist a fresh manifest onto the generation on disk A route-grant edit on the desktop moves no asset, so the bundle is published under the build id it already had: the cached generation holds the right bytes under a manifest an edit behind, and that stored manifest is the whole of an unreachable host's verdict. The store gains one operation for it. Refused unless the manifest names exactly the bytes on disk — same build id, and the same asset list read through the contract's own serializer, which is the string the id is a digest of. Written beside and renamed over, so a write that fails leaves the manifest the assets were downloaded with. A refusal is a return value, never a throw: it costs freshness, never the generation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the manifest through on a same-build cache hit The same-build hit in `onManifestRead` opens the cached generation and now also asks the store to rewrite the manifest beside it, and the generation the session holds carries the fresh routes from that point. Without it every offline verdict lagged a grant edit: `onCacheRead` reads the stored routes, and nothing on this path wrote them. The manifest travels whole on `manifest-read`, because the store compares its asset list and a projection rebuilt from the fields a transition reads would name other bytes. The fallback fixture that read a newer manifest under the cached build id now uses a build of its own, which is what makes the download it is about happen at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): every same-build manifest read persists, whatever the route verdict (round 1) A verdict about this route is not a verdict about the manifest. A fresh list that takes this screen native, or that names a bundle this shell cannot open, still grants or revokes the other routes the same assets serve — and what is stored beside those assets is the whole of the next offline verdict. Both returns left it unwritten, so an offline entry kept grants the desktop had already taken away. The same-build read is now taken before the route verdict: the native and wall returns carry the fresh routes on the generation they hold and emit the persist, exactly as the open does. The different-build arms are untouched, wall included, which still fetches nothing. `readMobileWebShellReachability` moves to `mobile-web-shell-reachability.ts`, the module its test was already named for: the reducer was one line under the 300-line cap and this fold needed the room. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the manifest swap crash-safe at the store (round 2) `moveFile` deletes the destination before it moves, because expo offers no atomic replace, so a failure after that delete left a generation with no manifest at all — which reads back as "no activation" and drops the host's cache. A phone whose host is unreachable loses its whole workspace to one interrupted rename. The store's fake threw before that delete, so the suite never exercised the ordering the adapter really has. The write is now three steps inside the generation directory: the fresh manifest to `manifest-next.json`, the old one away, the fresh one over it. The old manifest is never deleted before the whole of the fresh one is on disk, and `readActive` settles every window it leaves — both files present finishes the swap, a pending manifest alone is adopted, and one that is torn or names another build is discarded with the old one kept. A pending file that cannot be settled deletes nothing, because it may be the only manifest left and the next read can still adopt it. The asset-path guard refuses the pending name too, or an asset could be read back as an activation. `joinUri` moves to a module that imports nothing: taking it from the file-system module pulled `expo-file-system` into everything that addresses the cache, which is the import the store's testability rests on not having. The refusal rule now names where the id-to-assets refine actually lives. It is on the host's strict schema, not on the loose reader the phone parses with, so the store's asset comparison is the phone's own check rather than a restatement of one already made. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a walled same-build manifest is not persisted (round 2) Round 1 wrote every same-build manifest through, the walled ones included. That is the one read where the fresh manifest must not reach disk: an offline entry skips the compat check, so a stored manifest this shell has just declared it cannot read would have the next offline entry open a page under the grants of that bundle. What is on disk stays the last manifest this shell accepted, and the held generation keeps its routes with it, so the session's record still matches what was written. The native-route arm keeps the write: a route this build cannot serve is not a bundle it cannot read, and the fresh list still governs the other routes those same assets serve. Its round-1 test is now a control that pins the wall persisting nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the phone refuses a manifest whose build id is not its asset digest (round 3) The id is a cache key and a claim about content at once, and only the host checked the two against each other. The phone read the same document loosely and never recomputed the digest, so a stale or forged id reached the shell — which treats an id it already holds as the same bytes and opens the generation on disk without paging one. The reader now carries the host's refine, so a manifest that reaches the reducer under the cached build id names the same serialized asset list by construction. The two checks in that reader are one `superRefine` with the cheapest first and the first issue returning: the digest is the only one that hashes, and a manifest already over the allocation ceiling must not be hashed to be refused. The store's comparison stays, as the second reading of one rule rather than a rule of its own: its argument is a plain object, and nothing in the type says which parse it came from. The fixtures that published a literal id now derive it from the assets they name, which is what the host does. The one test that needs a single id over two asset lists still names it, because that collision is what it is about. Every one of the 13 manifests in the golden corpus was already digest-correct; the replay suites pass unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
86b93e02a7 |
feat(mobile): the microphone owns the wake lock, and the stop reply carries the tail (OTA phase C, ruling 36) (#22072)
* feat(mobile): give the microphone its own screen lock (OTA phase C, ruling 36) An open microphone holds the screen; a closed one gives it back. The lock lives in the device-side capture on both hosts — the shell's `native.audio.start|stop` handler and the native seam — so the page never decides anything about the screen. One tag per capture, minted by the module that owns the mic. Both captures give it back on every close path: a stop, a page session ending with the capture open, a device that would not begin, and an engine that throws after the capture is open, which now ends the capture rather than leaving it live. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): carry the capture's tail on the stop reply (OTA phase C, ruling 36) `native.audio.stop` drains what the ring still holds into its own reply, so the page's `end()` is one verb: stop, hand the bytes on, done. The drain, await and read-once-more ordering goes with it, and so do `ending`, `reading` and `released` — three variables that existed only to order a last read against the stop and to stop a refused read re-entering `end`. The tail fields default rather than being required: the page updates over the air and the shell does not, so a page this new can meet a shell that answers `stopped` alone. That dictation loses its tail where a required field would have lost it the stop. The heap case from PR D's bot round cannot recur: `end` issues no read, and a stop reply carries no interruption, so the lane that re-entered is gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the dictation finish id, which ordered nothing `finishingIdRef` tracked the dictation a stop was finishing, and every state it could name was already named: `cancel`, a disable, an unmount and a newer start each bump the generation or clear the active id, so the finish guard answered the same either way. Its one distinguishing arm released pending audio bytes for a dictation whose budget `closeDictationAudio` had just reset, and could subtract those bytes from a newer dictation's reserve. `acceptingChunksRef` stays: it is what stops a late microphone event being sent after the capture handed over its tail and before the finish goes out. `pendingChunksRef` stays: `stop` awaits it so the finish cannot overtake the last chunk send. The finish guard is pinned by a case that cancels while the finish is in flight; neutered, it reds. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): delete the page's wake-lock seam (OTA phase C, ruling 36) The page never names the screen now. `native.wakelock.set` is gone with its schemas, its shell server, its grant rows and its harness entry; so are the page's keep-awake owner, the Android foreground re-acquire, and the `DictationKeepAwakeDevice` the capture contract carried. One module holds the screen — the device calls the microphone's capture makes — and both device-side captures share its one tag, because there is one microphone. Deleted: native-wakelock.ts (120), native-wakelock.test.ts (140), mobile-dictation-keep-awake.ts (248), mobile-dictation-keep-awake.test.ts (440), mobile-dictation-foreground-keep-awake.ts (78). With the tag pools gone, the desktop-start flow has one stale check instead of two, no startup budget to wait out and nothing to release. A source-scanning census pins it: no module under mobile/src or mobile/app but the one owner imports expo-keep-awake, and nothing anywhere names the retired verb. It reports the file and line, and checks the owner does import the package so the absence is the rule holding and not the match missing. KNOWN RED, reported and not recorded over: 25 golden cases in the speech.* families fail. The recorder adapter had to drop its keep-awake owner, which moves `adapterSha256` for every golden that mounts it, and the deleted owner's id minting shifts the deterministic random sequence, so the recorded `dictationId` values move too. No speech.dictation.* param, reply or operation changed. Awaiting the lead's call on a scoped re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the corpus after the wake-lock deletion Baseline bumped to |
||
|
|
6731f08c0b |
fix(mobile): hold every hybrid shell switch on a neutral state while the flag is unresolved (OTA phase C, ruling 33.7) (#22077)
* feat(mobile): give the hybrid shell switches a third answer for the unresolved flag Every route switch read the flag as `enabled !== true`, which spends the window before the read settles on the native screen. With the flag on that window costs a full native mount — subscriptions opened, screen painted — that the shell then tears down and replaces. `shellSwitchDecision` answers `pending` there instead, and `ShellSwitchPendingScreen` is what a switch paints while it waits: the base background with nothing on it, lifted out of the `web` route where this view already was rather than written again. A route the shell could never open is still answered `native` with no wait, because the flag cannot change that outcome and a neutral frame in front of a decided one is the flash this removes. No switch is wired to it yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold every hybrid shell switch on the neutral state while the flag is null All ten switches read the flag as `enabled !== true`, so the window before the storage read settles was spent on the native renderer. With the flag on that window costs a full native mount — the session screen opens its terminal, chat and tab subscriptions — which the shell then tears down and replaces, and the user sees the native screen flash before the page. Each now asks `useShellSwitchDecision` and paints `ShellSwitchPendingScreen` while the answer is `pending`, so exactly one renderer mounts and it mounts once. `tasks` and `agent-history` build their route before the decision rather than after it, because the decision needs to know whether the shell is a possible outcome at all. `web` already had this frame inline and now takes the shared one; its spinner's accessible name moves from "Checking host" to "Loading". The two cases that pinned the old behaviour — "renders the native panel while the flag read is still settling" on the files and agent-history routes — now assert that neither renderer mounts there. `shell-switch-null-flag.test.tsx` drives all nine switched routes through the three states and counts committed mounts rather than renders. Five route tests gain a `react-native` mock, which the neutral screen's `View` is the first thing in their graphs to need. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fence the neutral state across every hybrid shell switch Reading the flag through the shared decision is not on its own enough. A tenth switch could ask `useShellSwitchDecision`, ignore `pending` and fall through to its native screen, satisfying the reader rule and still flashing native in front of a flag-on user. So the census also says every switch names the neutral screen — existence, not shape; where it names it is the route tests' business. `matchesOf` is the snippet reader beside the three rules: the needles are identifiers, and a list of paths says which file moved but nothing about what in it did. Verified as a fence by deleting the `pending` branch from the tasks switch, which the rule caught and named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): measure what the neutral window costs a released phone The only thing this change costs a user with the flag off is the window itself, so it is worth a number rather than a claim. `loadMobileWebShellEnabled` answers `false` outside `__DEV__` before it looks at the key, so a release build reaches AsyncStorage zero times: the window is React's own passive-effect flush and one microtask, not a bridge round trip, and the switch has its answer on the first turn after the first commit. Pinned per switch, because a reader that grew a storage call would move it from a microtask to a bridge hop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the switch table so the tests-typecheck ratchet reads it `as const` on the table made the catch-all's `page` a readonly tuple, which `useLocalSearchParams`' own param type does not admit, and the file dropped out of `tsconfig.test.json`. An explicit element type says the same thing and checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep release builds out of the neutral state entirely Review round 1 (pullfrog). The hook started at `null` on every build, so a store build committed one neutral frame before its native renderer — the one cost this change landed on every released phone, and it bought nothing there, because the flag cannot be turned on outside `__DEV__`. `mobileWebShellFlagCanBeOn` names that build-kind test once, beside the reader that already made it, and the hook starts its state on the answer. Outside `__DEV__` the hook holds `false` from its first render, the `pending` branch is unreachable, and a store build commits native on frame one. The effect still runs and still answers `false`; the initialiser is a starting point, not a second read path. Red-first, both build kinds pinned rather than inherited from the runner: 18 of 54 cases failed — "commits native on its first frame" and "does the same when the bundler defined no `__DEV__` at all", nine switches each. The neutral screen is mocked with a mount counter now, because a frame committed and replaced inside one `act` leaves nothing in the tree; its shape stays pinned in the web route's test, which renders the real one. The census gains the build-kind fence as a third rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9267423f22 |
refactor(mobile): the bridge host mount reads one args ref, and the handshake fact lives in the session (OTA phase C, audit item 3) (#22080)
* test(mobile): pin every bridge callback to the render on screen The host is built once per session, so each callback it holds has to reach the render that is on screen rather than the one that built it. Only the page fault was pinned that way; this covers the eight a frame can reach, and asserts the host was not rebuilt for the re-render by leaving a request open across it. Green before the collapse that follows: the pin is an invariance guard, not a reproduction. Verified to fail when a callback is closed over instead of read. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read the bridge host's props through one args ref Sixteen latest-callback and route mirrors, sixteen assignments and a sixteen- entry dependency list become one ref holding the whole props object, written in one layout effect with one dependency, and one adapter built with the host that reads it at call time. Adding a callback was a three-place edit the React Doctor gate has caught four people missing; it is now one entry in the args type and one line beside the host. The props type is named for that: the host is built against the whole object. `viewRef`, `hostRef` and `establishedSessionRef` stay, as do the host's own `serving`, `initSent` and report-once flags. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the handshake fence to the session, not to the mount Red on this head, in three places. The bridge hook has no way for a caller to say the session is already open, so a page whose host was rebuilt for a new client is refused; the session hook does not report the handshake its reducer already records; and the screen has nothing to hand over. The second bridge case is the control: the same frame on a session the caller says nothing about is still refused, so the first one is the fence moving rather than the fence going. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the bridge handshake fact into the session it describes `establishedSessionRef` kept "this session has completed a handshake" for the life of one mount, so the fence a rebuilt host inherits was remembered beside the session rather than by it. The reducer already records the same fact as `pageReady`; the session hook now reports it and the screen hands it over, so the bridge host takes it from the render. `options.sessionEstablished` stays. It is the host's own seed for `initSent`, which is the fence a rebuild has to inherit, and the red cases above are what say so. The shared bridge harness gained the screen's half of that: it records the handshake on `onPageReady` and re-renders, which is what the client-rebuild case was reading off the deleted ref. No existing case body changed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the dependency list from the bridge args refresh A caller builds the props object inline, so every render is a new one and there is nothing for the list to compare; React Doctor reads that as a dependency recreated each render, and it is right that the list says nothing. No list is what the ref is for: it runs after each commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3cfb070294 |
feat(mobile): register the session page route (OTA phase C, C7.7) (#21977)
* feat(mobile): switch the session route to the shell, still unregistered (OTA phase C, C7.7) The review switch's shape, for its reasons. The session screen becomes `MobileSessionRouteScreen` in `src/session` because `useMobileSessionController` is 32 hooks deep and opens the terminal, chat and tab subscriptions: at the switch's top level it would open every one of them behind the page as well as in front of it, since hooks cannot be conditional. As an element passed for `fallback` it is built and not mounted. Four query params carried rather than re-derived, each omitted when empty: `name` is a label the screen otherwise derives from the workspace, `created` is the create flow's one-shot flag, `warning` is the host's own text, and `paneKey` is a notification tap. `paneKey` is the one the screen writes back — `use-notification-pane-navigation.ts` rewrites it to empty once it has switched, through `setParams` on the handoff, which inside the page is the document's own router — so it has to arrive in the page for that to happen at all. Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it; until then the flag is the only thing that changes and it is off. Three censuses red without their rows, measured on this tree: - `shell-screen-route-census.test.ts` `walks the route tree and finds them` named `session/[worktreeId].tsx` as a ninth switch the list did not have. - `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no others` reds without `SESSION_ROUTE` in `SWITCHED_ROUTES`. - `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` named the new sibling. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): root the session parity family at the screen the route mounts (OTA phase C, C7.7) The extraction parity pin walks from a root function in `app/h/[hostId]/session/[worktreeId].tsx`, which is now the flag switch: the walk found no `SessionScreen`, and the runtime-string count went 534 -> 542 on the switch's own param names and path literals. Rooted at `MobileSessionRouteScreen` instead, which is the function that calls the controller. The switch's business is which of the two screens renders, not what the session screen does, and its literals have no place in a hash about the extraction. Every pinned hash is unchanged, which is what says the body moved and nothing else did: 275 hooks, 77 callbacks, 24 effects, 534 runtime strings, 124 host and 61 leaf JSX facts, 172 style references, all at the same SHA-256 they had before the move. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the page the session screen's stored preferences (OTA phase C, C7.7) Ruling 7: nothing silently no-ops. The allowlist was one exact key and one prefix, so every preference the session screen reads inside the page fell back to its default and kept working outside it — a state the user cannot tell from a preference that does not exist. The keys are derived from the route's own closure, not copied from design §6. Nine join the list: `orca:terminal-accessory-layout`, `orca:custom-accessory-keys`, `orca:defaultSessionView`, `orca:mobileStructuredSendOperations:v1`, the three terminal preferences ruling 7 names (`orca:terminalTextScale`, `orca:terminalAutocompleteEnabled`, `orca:terminalLinkOpenMode`), and two the design did not: `orca:hostDockWidth`, which `use-mobile-dock-resize.ts` drags on this screen, and `orca:hostSidebarWidth`, which `app/h/_layout.tsx` reads above every page route and which the manifest already names as the reason agent-history declares `storage` at all. Two are per workspace, not per host. Design §6 has `orca:nativeChatTabs:<worktreeId>`; the module builds `<prefix><enc(hostId)>:<enc(worktreeId)>`, and `orca:terminalLiveInputDisabled:` has the same shape. So the narrowing goes one level in from C2.9's: `pageStorageKeysForRoute` and `isPageStorageKeyForRoute` replace the host-scoped pair, and a session page opened on one workspace can no more rewrite the tabs of the one beside it than it can another host's pins. Both sides read the workspace off the route pathname, which is the one fact the shell and the page are each handed. Every new key's writer notes the mirror before it persists, as `savePinnedIds` does: `init` is built synchronously, so a write that only reached the store would be one `init` behind. A refusal is a rejection, not a dropped write. The real AsyncStorage rejects when its store refuses, and the caller that matters already catches: the durable send journal answers "Message not sent" rather than putting a mutation on the wire with an operation id no store holds, which after a crash would send the message twice. `PageStorageRefusedError` names the key and which of the three refusals it was. Measured on this tree, which is why the journal needed more than an allowlist entry: one journal entry with no attachment serializes to 342 characters and 48 unsettled sends put the value past `PAGE_STORAGE_MAX_VALUE_CHARS` (47 is under it), against a schema that admits 4,096. `init`'s own `BridgeInitStorageSchema` refines on that bound, so handing the journal over whole refuses the *frame* and the session screen never opens at all. `pageStorageEntriesForInit` drops such a value and names it; the page reads a default, which is a degradation rather than a page that does not start. Red first, measured here: - 21 cases across three files on the host-scoped helpers being gone. - `leaves out a value the page would refuse the whole frame over` reds with the filter bypassed. - The journal case reds without the rejection, with the operation claimed against a store that never took it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): register the session page route (OTA phase C, C7.7) One entry in `MOBILE_WEB_PAGE_ROUTES` with ten grants, every one read off a call site in this route's own closure rather than carried from design §1. Measured here: navigate 7 handoff sites externalLink 6 openers haptics 24 trigger sites native.clipboard.write 6 sites native.clipboard.read 3 sites native.media.* 2 sites, one seam (`useMediaPicker`) screencastBinary 1 site (`MobileBrowserPane.tsx`) storage 10 exact keys and 2 workspace-scoped, the previous commit's `pageRouteGrants` is derived from this list, so the row is a consequence of the entry and there is no second table to edit. The design's list was exactly right; the counts are what say so. The hop census goes 16 -> 23, measured. All seven new rows are `X -> /h/[hostId]/session/ [worktreeId]`, one from each other page route, and none goes the other way: the session's ten grants are a strict superset of every other route's, so every hop into it is handed to the shell and every one of its own targets stays in the document. That second half is asserted as grant coverage rather than as the absence of seven rows — absent is also what an unregistered route looks like, which is the shape C4 already had to correct once. Two censuses gained the route and one is new: - The haptics seam census, whose route-module map moves to `mobile-web-app-page-route-modules.mjs` so the new census below shares it rather than keeping a second copy that stops growing when the first one does. - `page-served-back-control-a11y.test.ts`, which named two controls with no `accessibilityRole`: `MobileSessionHeader.tsx:64 role=none label=Back to worktrees` and `QuickCommandsSheet.tsx:160 role=none label=Back`. Both get the role. Inside the shell there is no native chrome behind them, so a bare Pressable is absent from the accessibility tree. - `mobile-web-app-screencast-lane-grant.test.mjs` derives `screencastBinary` from the closures the way the haptics census derives its token. C6 could not write it: the pane is mounted by a route rather than registered as one, so there was no route to pin the grant against (C6 ruling 3). The derivation census gains C6's half measured against this route rather than against a module closure read on its own, which is the other half of C6 ruling 3. The composed row for the session route's own families waits on C7.8's table, and on C4.5's split before it. Numbers, both ends measured on this tree, never summed: - Session route closure 4,328 -> 4,329 modules, 978 -> 979 local. The +1 is `MobileSessionRouteScreen.tsx`; the route file is one input either way, now the `.web.tsx`. - Chunk count 65 before and 65 after, against the 72 the fence allows at 14 route keys. The fence is untouched: a `.web.tsx` sibling is not a new route key, and this route shared its split. - Bundle 8,020,519 -> 8,022,202 bytes, 108 assets either side. `mobile-web-app-route-chunk-closure.mjs` looked the route module up by its exact path, and `resolveExtensions` puts `.web.tsx` first: the first route with a sibling to be asked for reached "no output". It tries the sibling first now, which is what the build actually chunked. Without the manifest entry these red on this tree: `pins every hop the handoff must take away from the page`, `keeps every hop out of the session local`, `declares only routes the bundle has a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`, both haptics-seam cases, and `declares the screencast lane on exactly the routes whose closure asks for it`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render-check the session route, and quiet the two things it found (OTA phase C, C7.7) The render check mounts the registered route in a real browser on the built bundle, under the header the shells send. It asserts the session screen paints rather than the Unmatched route, that the Back control reaches the accessibility tree as a real `<button>` with its name, that the route's own chunk arrives on a client-side navigation, that nothing it paints leaves the origin or logs a policy violation, and that the three reads the screen makes carry the workspace the route named — the precondition the rest needs, since a screen that mounted and asked for nothing would paint the same chrome. It also asserts, strictly, that the page and console errors are `[]`, which is what found both fixes here. Measured on this tree before them: two console lines and one uncaught rejection on every mount of the route, none of them visible natively. - `use-mobile-session-markdown-actions.ts` registered `BackHandler.addEventListener` with no platform guard, and the effect re-registers whenever the dirty-draft list changes. React Native Web answers "BackHandler is not supported on web and should not be used." and hands back an inert subscription, so the guard was never armed on the page anyway. Gated on `Platform.OS`, as the right drawer, the bottom drawer and the file preview already are. There is no hardware back in a WebView; the shell owns the phone's, and the page's Back control is where the prompt lives. - `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch. The loader returns on a *refused* `worktree.show` and nothing caught a *rejected* one, so a host that will not answer produced `Uncaught (in promise)` on every session mount. Caught at the effect rather than inside the loader, whose promise the golden recorder awaits; notes that did not arrive leave the ones on screen as they were, which is the module's own policy for a refusal. **The terminal is not painted here and the file says so at both ends.** A terminal on screen needs the host protocol handshake, a tab snapshot, a terminal inventory and a `terminal.subscribe` stream — five hand-written fixtures against five Zod schemas inside a transport double, which is what the harness's docstring refuses to become. Scripting `status.get` alone was measured here: the protocol gate reads it and the page paints "Update Orca on your computer" instead of the screen. What the terminal does under the shipped header is `mobile-web-app-terminal-render.test.mjs`, on the same component and the same build options. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh the session parity pins for the two seam edits (OTA phase C, C7.7) The previous commit's two fixes are inside the parity family, so three pins moved. Both edits are one token each and neither changes what a phone renders: - `'web'`, the `Platform.OS` guard the Markdown actions' `BackHandler` registration gained. - `"button"`, the accessibility role the session header's Back control gained. Runtime strings 534 -> 536, with the effect hash and the host-JSX hash moving for the same two. Everything else is unchanged: 275 hooks, 77 callbacks, 24 effects, 61 leaf JSX facts, 172 style references, all at the SHA-256 they had before. A separate commit because a reported head does not move by amend, and because the moved hashes are worth reading on their own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the diff-notes rejection instead of catching it (OTA phase C, C7.7) The `.catch` the previous commit added to `loadDiffComments` moved a golden, which is a finding rather than something to record over: `matrix-session.diff-notes-worktree.show-1` certifies the unhandled rejection as an effect of its loaded checkpoint, so the corpus says the app raises it today and a fix is a re-record and a review event. Reverted to `void loadDiffComments()`, with the defect written where a reader of that effect will find it. `family-recordings.test.ts > session.diff-notes: reply partitions at worktree.show#1` is green again; it was the one failure in an otherwise clean 8,699-test run. The render check keeps the observation rather than losing it. Its error assertion is now the exact list `['RenderCheckShellDouble: the render check answers no RPC']` instead of `[]`, so a second error reds it and so does this one going away — which makes the file the place the fix is noticed when someone lands it with the re-record. The defect, for that PR: the loader returns on a *refused* `worktree.show` and nothing catches a *rejected* one, so a host that will not answer raises an unhandled rejection on every session mount. It is not a page fault — the shell's `fault` notify comes from the React boundary and nothing reaches it — so the generation is not dropped and the screen works; the cost is a document-level error on every mount. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session effect hash after the diff-notes revert (OTA phase C, C7.7) The effect pin was refreshed while `loadDiffComments` carried a `.catch`; reverting that (the fix moves a golden, so it is a finding rather than a line) moves the same hash back off it. Repinned on the uncaught `void` call, which is what the tree holds and what the corpus certifies. Count unchanged at 24 effects; nothing else in the family moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): reject a page storage write for size only, and log the rest (OTA phase C, C7.7 round 1) Ruling 33.4. `PageStorageRefusedError` was raised for all three refusals, and two of them have no catcher: a page-closure writer of an unlisted key awaits `setItem` with nothing around it — `notification-delivery-preferences.ts:39` plainly, `preferences.ts` in several places — so a key the page was never allowed to keep became an unhandled rejection in the document. That is a worse failure than the silent drop it replaced, and it is the one the page can least afford, because an uncaught rejection there is a document-level error on a screen that is otherwise working. Scope is now one refusal. `too-large` rejects, because the caller that needs it is written for it: the durable send journal's composer catches it and answers "Message not sent" rather than sending a mutation whose operation id was never written down (ruling 7). `not-allowed` and `not-delivered` resolve and are logged as `[page-bridge] storage-write-dropped`, which is the old behaviour plus the line a device log needs — a preference that did not stick looks identical to one nobody set. A batch applies every pair it can, logs every drop, and rejects only if one of them was oversize. Red first, measured here: seven cases in `page-async-storage.test.ts` red on the rejection, among them a `notificationDeliveryPreferences` write resolving, another host's pins, another workspace's chat tabs, and a write the shell would not take. The oversize case is unchanged and still asserts `PageStorageRefusedError` with the key and the character bound in its message, so the narrowing is visible as the difference between the two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): make every count in the session route say the same number (OTA phase C, C7.7 round 1) Ruling 33.5. Three numbers were stated more than once and two of them had drifted when the merge took the grant list from ten to fourteen. - Grants. `mobile-web-page-routes.mjs:100` and `mobile-web-page-route-hop-coverage.test.mjs:57` both still said ten. Fourteen in both, and the manifest comment now names the audio verbs beside the media ones as things only this route asks for. - Keys. The manifest said `storage` covers "the ten exact keys and two workspace-scoped ones", which counts `orca:last-visited-worktree` — a key this route did not add. Nine exact plus the two workspace-scoped, which is what C7.7 put in `page-storage-keys.ts`. - The journal entry. 342 and 343 are both real and answer different questions, which is exactly why one number had to win: an entry serializes to 342 characters on its own and costs 343 in the array, the difference being the comma that joins it. 343 is the one that drives the threshold, so it is the one stated, with the 342 kept beside it as its derivation. Re-measured here rather than carried: 47 entries are 16,140 characters and 48 are 16,483, against the 16,384 cap. Comments only; no behaviour and no assertion moved. The threshold case in `mobile-structured-send-page-storage-refusal.test.ts` already asserted the boundary both ways and still passes unchanged, which is what says the arithmetic above is the code's and not the prose's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): red-first for a pane request over a re-sent init (OTA phase C, C7.7 round 1) Ruling 33.1's four cases plus the compatibility one, all red: `publishRoute` is not a member of the host, `onRouteUpdate` is not a member of the page's client, and `ready` carries no `accepts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): deliver a pane request to the mounted page over a re-sent init (OTA phase C, C7.7 round 1) Ruling 33.1. The session switch keyed on the whole route, so a notification tap for another pane of the session on screen either remounted the shell (a bridge teardown and a page reload for a tab switch) or, for the pane already showing, moved nothing at all: the page cleared `paneKey` on its own router and the native param kept it, so `SET_PARAMS` wrote the value already there. `paneKey` leaves the key and travels as a route update. The page declares `accepts: ['route-update']` on `ready`; the shell re-sends `init` for a same-path param change only to a page that declared it, and treats a second `init` for the session the page already holds as a route update rather than a replacement -- in-flight requests, subscriptions, the storage snapshot (the same object, asserted) and the generation all stay. The screen reports delivery and the switch clears the native param, so no later `init` replays a spent tap. `use-notification-pane-navigation.web.ts` reads the request off a standing listener; the native file is unchanged. Wire-compatible both ways without a version bump: `accepts` is optional, an older page is never sent a second `init`, and an older shell never sends one. Both degrade to today's lost repeat tap. `BRIDGE_PROTOCOL_VERSION` and every released native RPC are untouched. Two files were at their line cap, so two modules came out at their own boundaries rather than a cap bump: `bridge-init-route.ts` (the route half of `init`, wanted by the switches, the host and the page) and `bridge-host-route.ts` (one host's held route and what it may publish). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the session switch and its hardware-back gate get their own tests (OTA phase C, C7.7 round 1) Ruling 33.2. `mobile-web-shell-session-route.test.tsx` mirrors the eight cases the files switch has -- route built, native fallback while the flag settles, repeated params, dot-segment refusal, segment encoding, flag off, remount on a route change, remount on a param change -- plus the two pane cases: a repeat tap for the same pane reaches the mounted page twice and a different pane reaches it once, both with one mount in the lifecycle. The `BackHandler` gate gets a unit test in the shape of its three siblings. Reaching it meant the hook declaring the fourteen fields it reads instead of taking all 268 of the session model, so a probe can render it without building a session; `MobileSessionDiffCommentsModel` satisfies that by construction and the one caller is unchanged. No pin in the session parity census moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a call-site census for the six grants that had none (OTA phase C, C7.7 round 1) Ruling 33.3. `navigate`, `storage`, `externalLink`, the two clipboard verbs and the media three were pinned only by the list they were copied from, so striking any of them out of a manifest entry reddened nothing. Each is now derived from the route's own closure by parsing the call sites -- a call, not a mention in a comment or a string, and not an import the module never calls -- and each row has a named control case driven over the session entry with that row's grants struck out. It found one thing. `app/h/_layout.tsx` wraps every `/h` route in `HostProtocolGate`, whose wall offers an Update Orca link through `openExternalLink`, and two routes reach that without declaring `externalLink`: on them the link posts a notify the shell refuses. Recorded exactly as `KNOWN_UNDECLARED` rather than exempted, because widening two other routes' grants is a capability decision and this is pre-existing on main. `notificationPaneTab` moves to its own module. A `.web.ts` sibling cannot import its native neighbour by the plain path: the bundler's `resolveExtensions` answers with the `.web.ts` file, so that import was the file itself and esbuild refused the page bundle with a cycle. The mobile suite does not bundle, so only the closure walk saw it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a struck-out grant red a case named after it (OTA phase C, C7.7 round 1) The first shape checked the whole manifest at once, so removing any one of the eight reddened all seven cases and named none of them: the per-row control read `session.grants` off the manifest the removal had just changed. Each row now has its own manifest case, and each control is built from what the session route's closure reaches rather than from what its entry declares, so it stays green whatever the manifest says. Closures are memoised, which is what pays for walking all eight once per row. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the pane request in a ref, not in state (OTA phase C, C7.7 round 1) The changed-code quality gate's React Doctor found it: `no-adjust-state-on-prop-change`. A tap can arrive before the terminals have loaded, so the request has to wait; holding it in state meant the effect that consumed it set state on a prop change, and the stale selection renders first. The request waits in a ref now and a counter wakes the effect, so the effect reads and clears rather than adjusting anything. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): re-measure the session closure and list the new web sibling (OTA phase C, C7.7 round 1) The full `config/scripts` suite found both. The closure reads 4,326 modules and 984 local, two more than the merge, and the two are named rather than counted: `notification-pane-tab.ts` and `bridge-init-route.ts`. The pane hook's web sibling replaces the native file rather than joining it, so it costs nothing -- but it is a `.web.ts`, so it needs its row in `web-overrides.json` saying why the native one cannot run on the page. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the page off a journal init could not carry (OTA phase C, C7.7 round 1) Ruling 33.6, from pullfrog on `beda1cc384`. Dropping an over-cap value from `init` did not revoke the page's write access to it: the key stays in `pageStorageKeysForRoute`, so the page read no journal, `parseJournal(null)` gave it an empty one, and its first send wrote a one-entry value over the device's -- every native entry lost and a fresh `operationId` for an operation the native journal already held, which is the duplicate send ruling 7 exists to prevent. `pageStorageEntriesForInit` now reports `oversize` beside `dropped`: only the value-cap drops, because an entry-cap drop is a key that fits and the page's own write of it is the size the shell would have carried anyway. The shell sends those names as `init.storageOversize`, and a page write to one of them rejects with `PageStorageRefusedError` under the size contract of 33.4, which the composer already shows as "Message not sent". The native journal is untouched until the user is back on native or it drains. `storageOversize` is optional in both directions: an older shell sends none and an older page ignores it, which is exactly today's behaviour. No version bump; omitted rather than sent empty, so no golden moves. Red first, with the two states replaced by ones the shell produces. The 47/48 case drives `pageStorageEntriesForInit` rather than publishing a journal value the shell strips before `publishPageStorage` ever sees it, and the case that used to assert a successful write now asserts the native entries survive: it was the clobber, recorded as success. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): deliver a route update only when a param moved, and only after init went out (OTA phase C, C7.7 round 2) Round 2, findings 1 and 2, both red first. `onRouteUpdate` fired on every re-sent `init` for the session the page holds, not only on one whose route moved. The shell answers every `ready` with the route it holds and the page re-asks on its own backoff and again after a refused `state` frame, so one tap reached the pane hook as `['', 'pane-1']`. Both ends now read one definition of moved, `bridgeRouteMoved`, which is the page's own `shellScreenRouteKey`: the host will not send an `init` for a route that did not move and the page will not publish one it was sent anyway. The `.web.ts` hook keeps its empty-pane guard and its comment now says why it is load-bearing rather than defensive -- the shell's own clear arrives as a move. `onRouteDelivered` ran on the `ready` path without checking that an `init` had gone out. A refused route answers the ask with nothing, so the caller would clear a one-shot param the page never received. `sendInit` reports whether a frame left and `onPageReady` carries it. Unreachable from the session switch, which parses the route before it mounts the shell; the prop's contract says it anyway, and the publish path already honoured it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): attach the init-storage doc to its type, keep the overrides escape (OTA phase C, C7.7 round 2) Round 2, findings 4 and 5, neither a behaviour change. The block describing `pageStorageEntriesForInit` had `PageStorageForInit` and its own one-line doc between it and the function, so it documented neither. The type moves above it and the block sits on the function it describes. `web-overrides.json` had an escaped em dash re-encoded as a literal one when this branch added its rows through a JSON round trip, on a line about the keyboard stub that has nothing to do with C7.7. Main's `—` is restored; `oxfmt --check` accepts the file either way, so this is main's spelling kept rather than a formatter's demand. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): catch the custom-key save the page store refuses (OTA phase C, C7.7 round 2) Round 2 addendum. `addKey` awaited `saveCustomKeys` with no catch and both of its callers are `void addKey(...)`, so the rejection had nowhere to go. `orca:custom-accessory-keys` is in the session route's page allowlist and a page write over `PAGE_STORAGE_MAX_VALUE_CHARS` rejects rather than drops (the size contract of 33.4, extended by 33.6 to a key `init` could not carry), so past ~16 KB of accessory keys this surfaced as an unhandled rejection in the page -- which the fault boundary reports and which drops the generation. Every other allowlisted writer in this closure already catches: the two write chains in `TerminalShortcutSettings`, the live-input save and the session-view preference. Caught at the boundary and logged, and the drawer neither announces the key nor closes: a row on the accessory bar that no store holds, gone at the next load, is the failure the allowlist exists to avoid. Red first -- the case saw the refusal escape with the page's own message -- and the control reds again when the catch rethrows. Belongs in `8b4c559e90` by the brief; it is its own commit because that one was already made and amending is forbidden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): reject a batch of oversize writes once, not once per pair (OTA phase C, C7.7 round 2) CodeRabbit and pullfrog, same site. `settleBatch` called `settle` per refusal and kept the first rejected promise, so a `multiSet` or `multiRemove` with two over-cap pairs built a second rejected promise nobody held -- an unhandled rejection in the page, the outcome ruling 33.4's rejection scope exists to avoid. Two oversize keys is all it takes, and `storageOversize` made a second way to reach it. A refusal is now an error or nothing, and only the caller's one rejection ever becomes a promise. Red first under an `unhandledRejection` listener with two over-cap pairs: one orphan before, none after, and the caller still hears about the first key. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): record a route key only once a frame carried it (OTA phase C, C7.7 round 2) CodeRabbit on `MobileWebShellScreen.tsx:271`. The effect recorded the route's key and then published, so a publish the hook refused for having no host was remembered as though it had gone out. `publishRoute` is now keyed on everything the host is built from rather than on the session alone, so the render that brings the host re-runs the effect, and the key is written only after a frame has left. Reported honestly: this does not repair a lost tap, and the case beside it says so. The host is built from the route the render holds, so a route that moved before it existed rides the first `init` either way and `publishRoute` then answers "did not move". What the change removes is a key recorded for a frame nobody sent -- the same contract finding 2 fixed on the `ready` path. The case pins the delivery count across the gap: nothing reported while there is no host, nothing reported once there is one and it has sent nothing, and exactly one report when the `init` answering the page's ask carries the route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse an oversize-dropped key at the shell, not only at the page (OTA phase C, C7.7 round 2) pullfrog's rollout gap on ruling 33.6. `storageOversize` is honoured by a page built with it, and the page is served from the desktop: a document from an older bundle ignores the field and writes the key whole, which for the send journal replaces every entry the device holds. The shell is the half that updates with the app, so the shell is where the refusal has to live. The host now refuses a `storage` notify for a key it could not hand the page, answering it as the drop it already answers an unlisted key with. The page's own rejection stays as the fast path -- it reaches the composer as "Message not sent" with no round trip -- and the schema comment says the field is advisory and the shell enforces it. Red first: a host holding the journal as oversize received a page write for it and posted it to native storage; now it posts nothing and the entries survive, while a key it did hand over is still writable. The three refusals became one predicate in `page-storage-keys.ts`, where the keys are, because inlining the third put `bridge-host.ts` over its line cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's pane hook gets its own test (OTA phase C, C7.7 round 2) pullfrog: `use-notification-pane-navigation.web.ts` had no cover. The native file's test mounts the native file, and `bridge-route-update.test.ts` stops at the client, so the half that turns a route update into a tab switch was untested. Seven cases: the seed from the route the page was opened on, a request held until the terminals load, a repeat tap on the pane already showing, a different pane, the clear the shell posts after each delivery, a pane that has since closed, and a page opened on no pane at all. Two controls, so the cases are not all satisfied by one behaviour. Dropping the seed reds the two that read the first `init`. Deduplicating by value instead of counting deliveries reds the repeat tap, which is the case the counter exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): roll the custom-keys mirror back when the store refuses (OTA phase C, C7.7 round 2) CodeRabbit. `saveCustomKeys` notes the write in the mirror before it persists, because a reader is answered from the map rather than from the store and the shell builds `init` synchronously from that map. On a refused write the note stood: the page's next `init` carried the value native had rejected, and every native reader of the key saw it too. The previous mirrored value is captured and put back on the failure path, and the error still goes to the caller so `addKey` keeps withholding the key. Red first: with the store refusing, the mirror held the rejected value where the pre-save value belonged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report a route delivered only after its frame was posted (OTA phase C, C7.7 round 2) CodeRabbit on the send path. `sendInit` answered "sent" the moment it handed the JSON to `post`, and a rejected post was reported a turn later as a diagnostic -- so the screen spent the one-shot `paneKey` on a frame the page never received, cleared the native param, and the tap was gone. `publishRoute` was fire-and-forget the same way. Delivery is a promise now, settled after `options.post` resolves and false on either throw or reject. Readiness stays separate: `onPageReady` fires on the ask, as the shell's wait needs, and carries the delivery promise beside it. The screen records the route key and calls `onRouteDelivered` only when that promise answers true, and a refusal leaves nothing recorded so the next render that can carry the route tries again. Red first: with the view refusing what it was handed, the frame was built and posted and the screen reported delivery anyway. Now it reports none while the page's ask is still reported, and a host-level case pins the same split. `bridge-host.ts` was at 299 of 300 lines, so the send half came out as `bridge-host-frames.ts` rather than growing it; the file now measures 280. The screen's own test harness never attached the view handle, so every post in it rejected unobserved -- it attaches one now, which is what let the case see the frame at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): fold two doc blocks back onto what they describe (OTA phase C, C7.7 round 2) pullfrog's two nits, no behaviour change. `page-async-storage.ts` kept the old `settle` block above `refusalError` when the function it described moved down with a one-liner of its own; the orphan goes. `bridge-host.ts` had two stacked blocks on `sendInit` after it grew a return value; they are one, and it now says the frame is still built synchronously and only the post is awaited -- which is the property the golden recorder depends on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the host own a pending route until its frame lands A route the page has not received is now the host's, not the screen's. `publish` keeps it pending until a post resolves true, marks it delivered only then, and reports that through a callback registered once per host. Movement is measured against what a frame actually reached the page with rather than against what the host holds, so a refused frame leaves the route owed instead of reading as one that did not move. Three things the old shape lost, each a case here: a frame the view refused was never retried, because only another render could try and a mounted page has none coming; a render while a post was in flight cancelled the report the switch spends to clear the param; and a repeat tap for the same pane was held, because the host had already moved its held route on the attempt that failed. The retries are the moments delivery becomes possible again — the next `ready`, and a view handle the host regains — and one frame goes out at a time. Also folds round 4's doc nits: the stale delivery comment the screen no longer has a ref for, and a leftover `an` in the `ready` branch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): roll the journal mirror back when persistence fails `writeEntries` noted the mirror before the store took it, which is what keeps an `init` built in the same turn current — but it kept the note when the store refused. The page then received a journal the device never wrote and resumed operations nothing was holding. Restored on the error path, the same shape as the custom-keys save, and on both halves: the removal that empties the journal had the same gap as the write that fills it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep publishing until the held route is the delivered one Two halves of the same gap, both found by the bots on `fba8cb3f3d`. A route that moved while a frame was out was held for the turn and then had nothing to wake it: the post settling only cleared the in-flight flag, and on a mounted page no `ready`, handle or tap need ever come along. A landing is now itself a moment to publish again, while what the host holds is not what the page has. Only on a landing — a refused post that re-attempted itself would spin, and that one still waits for whatever makes delivery possible again. And the report carries the route a frame reached the page with, which the session switch was ignoring: the older pane landing wiped the `paneKey` naming the newer one, so the page stayed where it was and the second tap was gone. The switch now spends the param only for the pane that was delivered. The delivery cases render through one helper rather than six copies of the same setup, which is what keeps the file under its cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop the second block describing a parameter onPageReady no longer takes The field is documented by the block above it; this one still described the `delivered` promise the handler was handed before the host took ownership of the pending route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the page erase the route param it was handed Ruling 34, step one: one page-to-shell frame that asks the shell to clear a one-shot route param, naming the value the page applied. Closed at both ends. The param is an enum of what the shell hands over, so a page cannot edit a route it was never given; the notify name is a member of the closed union, so it gets a row in the grant table by compilation rather than by memory, and rides no grant because it can only spend something this shell put there. The shell declares it in `init`, the mirror of `ready.accepts`: no shipped shell serves a page, so nothing needs negotiating today and the page's check exists from the first version that can post one. The comparison belongs to whoever holds the param, which is the session switch: a tap that moved on while the page was applying the one before it leaves a newer key, and a clear naming the older one is not for it. `bridge-envelope.ts` went over its cap, so the page-to-shell union moved to `bridge-notify-envelope.ts` and the fields both halves spell to `bridge-frame-fields.ts`, which the envelope re-exports. No cap was raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): re-send init on a route change and track nothing else Ruling 34, step two: the tracked handoff is gone. Deleted, not patched — the pending route, the delivered route, the in-flight flag, the landing callback, `retryPendingRoute`, the delivery promise `onPageReady` used to carry, and the `onRouteDelivered` that ran from the host through the hook and the screen to the switch. What is left is the rule in one line: `publish` sends one `init` when the route moved and the page said it takes one, and every `ready` is answered with the route the shell holds then. A frame the view refused is repaired by the next ask, not by a retry; the request it carried is spent by the page. The cases that tested the deleted mechanism go with it. The outcomes they protected are pinned where they now live: one frame per move and none for a render that moved nothing, a lost frame repaired by the next ask, no second `init` to a page that never said it takes one, and the repeat tap measured through the page's erase rather than through a delivery report. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page applies a pane and erases the request that carried it Ruling 34, step three. The page hook applies the pane an `init` names and asks the shell to erase the param it came on, naming what it applied. Two rules, and both are the page's because the shell has none. The erase is asked for on every `init` that carries a pane rather than only on the one that changed something: a clear that never reached the shell leaves the param in place, and the next frame carrying it is the repair. The switch happens once per value: a re-asked `ready` is answered with the route the shell still holds, and applying that again would drag the page off a tab the user has since moved to. A repeat tap for the same pane still arrives as a request, because the erase went through in between and the tap wrote the param back. The client refuses to post the frame to a shell that did not declare it takes one, which is every shell older than the field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the page bundle's readers pointed at the module that declares each name The envelope split left three source-text readers and one import pointed at a file that now re-exports what they read. `shell-screen-route.ts` read the route schema back through the envelope, which reaches that file again through the page-to-shell union: a cycle esbuild resolves to `undefined`, so every page route mounted onto a schema that was not there yet and the browser render suite failed on twelve files with a TypeError rather than on a build error. It reads the declaring module now. The render harness read `BRIDGE_PROTOCOL_VERSION` and `BRIDGE_FAULT_GRANT` out of the envelope by regex; both moved, and a regex over a re-export answers for whichever file the last split left them in. Both point at `bridge-frame-fields.ts`, and the throw names it. The session route's page closure is re-measured on this tree at 4,330 / 988 and the four new modules are named, not inferred: the two halves of the split envelope, and the route-update module and route-key reader the page-to-shell union now reaches through it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count posted inits with the page's own reader, not a cast The changed-code quality gate refuses a type assertion, and it is right to here: a frame the page's reader would refuse is not an `init` the page ever saw, so a case counting them must not count one either. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): put every mirrored write on one path that notes what the store took Ruling 35. Fourteen call sites in six files noted the shell's mirror before persisting, and on the page a persist can be refused: twelve left the map holding a value no store had taken, and the next `init` handed the page exactly that. Two undid it by hand. `persistMirrored` is the one path now, and it seats the map from what the store holds after the write rather than from what it was handed. That is what makes the note follow acceptance without a second opinion about it: the page's adapter resolves a `not-allowed` write and logs it, so a rejection is not the only refusal there is, and reading back is the only answer that covers both. The cost is one store read per mirrored write on the device, where the store refuses nothing. `writeMirroredStorage` keeps its note-then-persist order and loses every caller but one: the shell taking a value the page has already applied, into the device store, which has no allowlist and no frame cap to refuse against. It builds the next `init` synchronously in the same turn, so noting on the store's reply there would hand the page back the value it just changed. The last-visited key moved off it, because that module is in the page's own closure. Both rollbacks are gone with the notes that needed them, and `noteMirroredWrite` is private. A source-scanning census holds each writer to the path by name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): answer a page batch write at the first pair it cannot take Ruling 35's other half. `settleBatch` collected a refusal per pair, logged each, and rejected with the first that could reject while the rest of the batch went in anyway — one promise describing a call where some pairs landed and some did not, which is not something a caller can act on. A batch is one call with one answer now: every pair before the refusal is applied, the refusal is the answer, and nothing after it is attempted. No page-closure writer calls `multiSet` or `multiRemove` today, so this is the rule for whoever writes the first one rather than a change to anyone's behaviour; both directions are pinned, including the refused first pair that stops the rest. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the mirrored write path's census and journal writer Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the note-first callers the mirror module says are held to one Two gaps pullfrog found in the census. The last-visited key moved onto `persistMirrored` with no row naming it, so removing its write path reddened nothing; and `mirrored-storage-keys.ts` says the census holds `writeMirroredStorage` to one caller while nothing counted them. Counted now, over every module under `mobile/src` rather than over a list of files a new caller could sit outside of: a second one is either a writer that wants note-then-persist without the store that earns it, or a page-reachable module that would note a refusal as an accepted write. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): fold sendInit's doc onto the function it now describes It still described an awaited post that answered whether the page received the frame, which ruling 34 deleted: it fires the frame and answers nothing, a refused route sends nothing at all, and a post the view would not take is one diagnostic and no further attempt. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the page own a frame it received and could not handle Ruling 34's addendum. On iOS the host's post is `callAsyncJavaScript`, which rejects when the page's synchronous `onmessage` throws — with the document still mounted. The shell reads that as a frame that never arrived, and it tracks nothing about posts, so nothing would ever send it again. It is not a lost frame either: the page had it, one of its own listeners failed, and a retry would fail the same way. `receive` catches it and reports `inbound-listener-threw`, so the delivery is the channel's and the handling is the page's. Nothing is swallowed and nothing is retried. Two cases pinned the throw escaping and now pin it being reported: the ack that a listener bug must not wedge, and the bootstrap stamp a tree that throws still leaves behind. With that path closed, a post is refused only when no document holds the view, and the comments on both halves of the route seam say so instead of naming a backoff that is stopped by then. The repair is pinned rather than described: a tap that arrives while the view is gone is carried to the next document's `ready`, because the held route advances on `hold` as well as on `send`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the page's held session out of the client, which was at its cap The listener catch put `bridge-rpc-client.ts` at 305 counted lines against a cap of 300, so this splits rather than bumps. The session is the one piece of the client with a lifecycle rather than a value: a second `init` for the same session updates it in place, a different one replaces it and takes the requests and streams of the session before it, and each case has its own listeners to fire in its own order. The client keeps the frames and the ports; `bridge-client-shell-session.ts` keeps what they are for, and the client's three members delegate to it. The client measures 277 counted lines after the move. The session route's page closure is unchanged at 4,330 / 988: the page reaches its client from the entry, not from the route module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5d51bcaf6e |
feat(mobile): serve any manifest route through a [...page] catch-all (OTA phase C, C8) (#21950)
* feat(mobile): add the page-route-unavailable refusal screen The catch-all route landing next has no native screen behind it, so its fallback cannot be a panel. Nothing imports this yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move firstParam out of the source-control tree Every caller is a route file under app/h/, and the import dragged mobile-git-status.ts and the screen-state module into the closure of any route that reads a param: 1989 modules (3 local) for a one-line helper, against 1 from src/navigation/route-param-reader.ts. Pure move. The three shell route suites drop their lucide-react-native mocks with it; that barrel was only ever reached through the old home. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve any manifest route through a [...page] catch-all Every page screen needs a route file under app/h/[hostId]/ today, so a screen the desktop registers after a store build has nowhere to mount and expo-router paints Unmatched. This adds one catch-all that hands any host-scoped pathname to the shell; the manifest still decides, through the same routeViewOf the other switches reach. Measured with expo-router's own matcher on both platforms: every route that has a file keeps it, index and the four .web.tsx siblings included; only pathnames that reached Unmatched move. The body lives under src/ because expo-router 55 reads a file's platform from the first dot of its stripped name: [...page].web.tsx under app/ parses as platform '' and registers a second route rather than overriding the first. Under src/ the stem is plain and Metro and the page builder both resolve the sibling. getRoutes shows exactly one [...page] key on each platform. Registers no manifest entry, grant, hop row or PAGE_SERVED_SCREENS row. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page refuses an unrouted host path instead of Unmatched The catch-all owns every /h/<id>/... pathname with no module, on the page as well as in the app, so the render check's unmatched case can no longer pass: measured in headless Chromium, /h/<id>/not-a-route paints the refusal with no page or console error. bridge-caps.ts records that C8 closes the C1.7 class for the host subtree, and its dot-segment note is rewritten to the measured mechanism: getStateFromPath normalizes the href through new URL(href, 'file:') in getUrlWithReactNavigationConcessions before cleanPath sees it, so /h/..?x lands on the app's root screen rather than on a host screen with hostId '..'. Refusing it stays correct. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the web-overrides allowlist in append order The catch-all entry was added with a whole-file sort, which rewrote 116 lines for one addition and buried it. The test compares sorted sets, so the order on disk is free; append order is what makes the diff readable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): leave the refusal on the encoded host route The Back href was a raw template, so a host id carrying a slash built /h/a/b — two segments, which the catch-all that rendered the refusal matches with hostId now "a". The control looped back into the screen it exists to leave. hostStackHostRoute already encodes it and is what the notification path pushes through. hostId is a string: firstParam returns one, so the undefined arm and its ?? '' were unreachable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the catch-all's fallback binding and its way out catch-all-page-route.test.tsx mocks both the shell and the refusal, so fallback={refusal} was unpinned: mutating it to null left that file at 10 passed. This drives the other half with the real shell screen and the real refusal under it, stubbing only what the session reducer settled on. Four cases, each measured against a mutant: fallback={null} reds three, push instead of replace reds two, a raw /h/${hostId} template reds the five-shape encoding case. The checking case is the presence precondition the rest need: before the flag read settles the switch returns the refusal on its own, with the same text and the same control, so an assertion on the refusal alone would pass against a screen no shell ever rendered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the route-body follower resolve every shape or fail The follower read only `export { default } from 'x'`. A route file doing `import X from 'x'; export default X` and mounting the shell without shellScreenRoute left the census at 5 passed: the body was never opened, so the file read as "not a switch" — the one answer a census must never give by default. Both shapes are followed now, and an unresolvable one is named rather than skipped. Measured against four mutants: a shellScreenRoute call dropped from the re-exported body reds the rule; an import-then-export route mounting the shell reds two cases naming the file; a default from a package specifier, a file with no default, and a re-export with no module each red the new resolution case with the reason. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): type the host-node lookup in the catch-all state test The tests-typecheck ratchet reds on findAllByType with a host string: react-native is mocked to strings here, which is not an ElementType. A findAll predicate on node.type is the same lookup and checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why the refusal does not absorb the other shell states The refusal answers one question — this build cannot serve this route — and offline, checking and the protocol wall answer different ones that are each true for a screen only the page has. Absorbing them would tell someone with no connection that the screen does not exist. Written where fallback is bound, and driven: an offline session through the catch-all paints the connect message. Mutating the shell to return fallback for offline reds that case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the type the shell-view probe stands in for The anti-slop gate refuses a broad `object` parameter, and it is right here: the probe forwards every prop to its host node, so the type it accepts is the view's own. Type-only import, so the module's requireNativeViewManager call is still never evaluated. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send the refusal to the app root when it has no host firstParam answers an absent hostId as '', so Back called hostStackHostRoute('') and landed on /h/ — which expo-router's own matcher resolves to the h layout with no child, a press that paints nothing and leaves the dead end in place. The app root lists hosts and is where ProtocolBlockScreen sends the same gesture from the same position; the label follows the target rather than outliving it. Also through useRouteHandoff rather than useRouter, which is the same defect on the other side: the page renders this screen through the catch-all's .web.tsx sibling, and there a bare replace navigates inside the WebView to a route the page does not carry instead of leaving it. ProtocolBlockScreen already uses the seam; the router-seam censuses cover src/session, src/files and src/source-control, not src/mobile-web-shell, so nothing caught it. Costs one module in the page closure (host-stack-navigation.ts): every module the seam reaches is already in the layout's closure. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): repoint C4.4's source-control web sibling at the moved reader The merge brought in a route file that imports firstParam from the source-control screen state, which this branch emptied. Git merged both sides cleanly because neither touched the other's lines; tsc is what catches it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): mock dictation's device half in the catch-all state test The merge put the audio verbs in the shell screen's closure, so this file reaches @orca/expo-two-way-audio, whose module touches the Expo global at import. Same two mocks MobileWebShellScreen.test.tsx carries for the same reason; what each verb does is bridge-audio-verbs.test.ts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the router seam under mobile-web-shell The three landed censuses walk src/session, src/files and src/source-control, which is how PageRouteUnavailableScreen shipped with a bare useRouter and nothing caught it until CodeRabbit. This tree needs a shape of its own because it holds both halves: the rule cannot be "no router" when MobileWebShellScreen and useShellStackPop are the app end the page's navigate and navigate-back notifies arrive at. Both are named with the reason, and neither has a .web.* sibling, so neither runs inside the page. Red first: with the bare useRouter put back, two of the four rules fail naming the file — + "PageRouteUnavailableScreen.tsx (useRouter)" - "PageRouteUnavailableScreen.tsx" The walk covers 72 product modules, asserted above 60, so the empty finding list is over a non-empty walk. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a91ca8b19e |
feat(mobile): dictation on the OTA page over native audio verbs (Phase C, C7.10 PR D) (#21905)
* feat(mobile): serve dictation capture over four native audio verbs The page owns dictation's state machine and speaks `speech.dictation.*` to the desktop, where transcription runs; the microphone is the shell's. So the shell gains `native.audio.start|read|stop` and `native.wakelock.set` — four rows, four grants — and rings what the microphone produces at the page's own pending-audio budget rather than pushing bytes the page would hand straight back. `native_audio_not_capturing` joins the refusal vocabulary: a read for a capture this session does not have is the one refusal the page must tell from a device that failed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): take dictation capture through one seam on both hosts `use-mobile-dictation.ts` held the microphone and the wake tag directly, so the page had a hook whose every device call was a stub answering denied. The five calls move behind `src/platform/dictation-capture.ts`: natively the same calls in the same order, on the page the shell's four verbs, with the drain raising the events the engine emits. The tag bookkeeping stays where it was and stops importing `expo-keep-awake`: two calls come in through the seam and the pools, the queue, the timeouts and the retries are the same on either host. The chunk sender is untouched. A chunk carries raw PCM because that is what the budget counts and what `speech.dictation.chunk` is built from, so the page pays one decode of 32 KB a second rather than the flow carrying two shapes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): drain the shell's audio ring on a 500 ms batch One `speech.dictation.chunk` per native microphone event is 31.25 forwarded requests a second, and each holds one of the bridge's 64 in-flight slots for a whole desktop round trip. Measured over ten seconds against a two-second link: 63 in flight at the peak and one slot left for the rest of the page. Drained every 500 ms instead: 5 in flight, 60 slots free, the same 42 KiB/s, and 38 frames out and 34 back for the whole session. The frame cap was never the bound — half a second of PCM is 3.3% of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): derive dictation's grant rows from the route closure Which routes need the four audio verbs is a census, not a hand list: the rule reads each registered page route's own closure and holds its `grants` to what that closure reaches. Vacuous on today's route list — the session route is the only closure carrying the seam and is not registered yet — so a control runs the same rule against the session module and names all four as missing. The closure also records what the seam took off the page: `@orca/expo-two-way- audio` and `expo-keep-awake` are gone from it entirely, and removing the web file puts four of the vendored stub's modules back. The mic control's render case found a real one. A start the shell refused outright left the button on "Starting voice dictation" with no way back, which is every tap on a route without the grants. It reports the refusal and returns to idle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the mic control's render case inside the tests typecheck A `let` the renderer assigns inside a callback narrows to `never` afterwards, and the mocked `Pressable` took `children` as `unknown`. Both are type-level only, and the ratchet is the gate that notices: a test outside `tsc` can pin a shape that stopped existing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand over the audio still in the ring before stopping the shell `end` cancelled the drain and stopped the capture without a last read, so up to one drain interval of the utterance's tail — 16,000 bytes, the 400 ms a user is still speaking as they lift the button — was discarded on every stop. The reviewer's probe spoke 12,288 bytes in the last 400 ms and the page delivered none of them. Natively that audio is already in the hook's hands, so this was a page-only loss of the end of every sentence. `end` is now asynchronous: it cancels the timer, waits for any read in flight, reads once more, and only then stops the shell — stopping first takes the capture away and the read after it is refused. `stop()` awaits it before it stops accepting chunks and before it takes the pending set, or the tail would be dropped one line later and `finish` could overtake the last send. A release still skips the last read: the screen is going away and there is nobody left to hand the tail to. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): release the page session's wake tag when the session ends The wake-lock server held its tags per instance and a new one was built per page session with nothing ever disposing it, so a tag a session took was never given back and the screen stayed awake for the app's lifetime. The page is a document that can navigate, fault or be swiped away mid-dictation, so nothing else was ever going to call deactivate. Its own docstring claimed the opposite. It now answers `{ serve, dispose }` and is disposed with the session exactly as the microphone and the staged media handles are. `dispose` drops only what is still held, so a tag the page already gave back is not deactivated twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the capability flag no screen reads `canCaptureAudio` answered four grants to nobody and had no test. The fence that actually holds is the per-verb `ungranted` check every member already makes before a frame is sent, and the mic control's render case pins what a screen does with it. The surface's member list is pinned instead, so the next flag with nothing behind it has to be added there on purpose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-measure the closure the dictation census records, and make its controls real Three corrections to the census, all of them about the census lying rather than the product being wrong. The recorded number was four modules; measured on this head it is eight — five from `@orca/expo-two-way-audio` and three from `expo-keep-awake` — for a net +7 once the local file that left is counted. The absolute closure counts are provenance in the docstring and are not asserted, because every merge of main moves them. The absence now has a precondition: both package names are resolved from the install, so a substring matching nothing fails as a typo. The case named "reads the census file" read no file. It reads the shell's own verb table through `import()`, behind the closure guard, so a grant the shell has no row for reds instead of agreeing with itself. And the grant control re-implemented the rule's filter inline. Both the rule and the control drive one function now, over the entry C7.7 would write if it copied its neighbours' grants. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serialise the shell's audio starts and stops Two starts racing the OS permission prompt — a page reloaded while it is up, which is the case the replacement rule exists for — both reached `listen()`, and the second overwrote the first's handlers without removing them. The engine went on calling into a capture nobody could read, and `dispose` freed one of the two. A stop that overlapped a start found nothing to end and the start opened a microphone behind it. Starts and stops now run one at a time in the order the page asked for them, and a start that comes back after the session ended opens nothing. Reads stay off the queue: they must not wait behind an opening capture, and a read with no capture is already a refusal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): end a capture on the same two interruptions on both hosts The page treated any interruption as the capture being taken away, while the native seam has always gated on `began` and `blocked`. So an `ended` on its own — the OS handing the session back after a notification chime — cancelled a live dictation on the page and did nothing natively. The rule is now one predicate beside the vocabulary it belongs to, read by all three places that decide it: the shell, which stops filling its ring; the native seam, which raises it off `onAudioInterruption`; and the page, which raises it off a read reply. `recording` still ends the page's capture whatever the kind, because a capture the shell no longer has is gone however it went. The native half had no test of its own, which is why the drift was invisible. It has one now: the five calls it makes, the chunk it hands over, the wake tag, and which interruptions end it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): type the chunk sender as what its callers pass The sender's parameter was the native `MicrophoneDataEvent` though both callers hand it a `DictationCaptureChunk`. Structurally the event is the wider type, so it accepted either and read `droppedBytes` off neither — a page whose audio the shell's ring had dropped would have sent it as though nothing were missing, and nothing would have failed to compile. Typed as the chunk, with a compile fence beside the seam holding both directions: a chunk is accepted, an event is refused, and a raw buffer is not a chunk. The seam normalises the bytes, so the widening the sender did has no caller left. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the session closure with dictation's page modules on it The capture seam moves this count down rather than up. Measured at |
||
|
|
23207bfde2 |
feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) (#21957)
* refactor(mobile): move the review route body onto a component and the handoff seam (OTA phase C, C4.4) The review route file called `useMobileDiffReviewController` at its top level. A switch cannot keep it there: hooks are unconditional, so the whole controller — its client subscriptions included — would run behind the shell's page whenever the shell renders. As an element passed for `fallback` it is created and not mounted, which is how the explorer switch already behaves. `useRouter` becomes `useRouteHandoff` in the same move. It was the one raw expo-router router left in the review closure (measured: the only other value import of one is the seam's own web sibling), and inside the page the session screen `openSession` replaces to is native, so that target has to be handed back to the app rather than posted into a document that does not render it. The params are read in the component rather than handed down, so this is the route body and the route file above it is free to become a switch. `session-router-seam-census.test.ts` gains the module by name. Kept with `useRouter` the census reds twice — `imports nothing from expo-router that can navigate` names `MobileDiffReviewRouteScreen.tsx (useRouter)`, and the completeness case gains `useRouter` — which is what forces the swap rather than leaving it to a reviewer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): switch the source-control and review routes to the shell, still unregistered (OTA phase C, C4.4) Both take the files switch's shape: `firstParam`/`firstReviewParam` on every param, `shellScreenRoute` as the one predicate, `MobileWebShellScreen` keyed on `shellScreenRouteKey`, the native screen built as an element and passed for `fallback`. Both gain a `.web.tsx` sibling for `index.web.tsx`'s reason — the native file reaches OrcaMobileWebShellView, whose module throws at import in a browser, and the route manifest imports every route. Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it, which is the next commit; until then the flag is the only thing that changes and it is off. Query params are omitted rather than sent empty, and the whole record is omitted when none was named: `tab=` is a lens named nothing and lands on `changes` through a different branch than an absent one, and the same holds for `name`, `origin`, `scope`, `file` and `area`. `pr` and `history` are deliberately not switched. Both are `Redirect`s into `source-control`, and a redirect inside the page would leave the session bound to a pathname the page has left; left native they replace into this route and its switch mounts the shell. Three censuses red without their rows, measured on this tree: - `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` names the two new siblings; `states a reason for every override` reds on a placeholder under 20 characters. - `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no others` reds without the two `SWITCHED_ROUTES` names. - `shell-screen-route-census.test.ts` `walks the route tree and finds them` reds without the two switch names. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) Two entries in `MOBILE_WEB_PAGE_ROUTES`, five grants each, with the reason for each grant read off the screen that needs it. The two lists are equal on purpose: the hub's changed-file rows push review and review replaces back, and a target declaring no more than its opener is a hop the handoff keeps inside the document. Registering either alone would have put a native frame and a second bridge session between a changed-file row and its diff. `pageRouteGrants` is derived from this list, so the two rows are a consequence of the entries and there is no second table to edit. `pr` and `history` stay native redirects and are never listed; the derived target list at this tree is [files, files/preview, source-control, [p], accounts, agent-history, review, session, tasks, web], with no `pr` or `history` row, because the census reads call sites and both redirects name `source-control`. Measured on this tree, not carried from the draft: - The hop census goes 8 -> 16. The eight new rows are exactly `{/h/[hostId], agent-history, files/[worktreeId], files/preview} -> {source-control, review}`, each handed off for `native.clipboard.write` and the first four also for `externalLink`. `source-control <-> review` is absent in both directions, which a new case now asserts as grant-list equality rather than as the absence of a row — absent is also what an unregistered route looks like. - The Back census now walks six trees and finds 8 controls, both rules printing empty. The two new ones are `MobileSourceControlHeader.tsx:46 role=button label=Back to session` and `MobileDiffReviewHeader.tsx:48 role=button label=Back`, which is what C4.3 bought. The `ARRIVING_SCREENS` describe it wrote for this moment is removed: with the rows in `PAGE_SERVED_SCREENS` its trees are covered and its cases were a second reading of the same thing. - Both closures reach the haptics seam, so `haptics` is declared by measurement: the seam census derives the reaching set and its two cases pass with the routes in its `ROUTE_MODULES` map. Without the two manifest entries these red on this tree: `pins every hop the handoff must take away from the page`, `keeps the hub and review local to each other`, `declares only routes the bundle has a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`, and both haptics-seam cases. `build-mobile-web-app-bundle.test.mjs` is split rather than fenced. The two pinned entries put it at 607 non-comment lines against the 600 cap, and the declaration block is a different concern from how the bundle is built — it grows once per registered domain while that file does not. It moves whole into `mobile-web-page-routes.test.mjs`, named for the module it is written against, so the next route to register does not have to choose between a lint fence and a split it did not ask for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render-check the two page routes, and make the oversized stage-all readable (OTA phase C, C4.4) The render check mounts both routes in a real browser, asserts each paints its own screen rather than the Unmatched route with no console error and no page fault, and asserts each fetches its own chunk on a client-side navigation. It also reads the shipped `img-src 'self' data:` out of the Kotlin source it is served with, pins the Swift twin beside it, and asserts neither route leaves the origin or logs a policy violation while it paints. The avatar skip itself (ruling 3) is `PRCommentCard`: on web it renders its existing empty-avatar `View` rather than letting one `<Image>` per comment attempt a fetch the policy refuses. Its branch is pinned by a component test, which reds on the platform check being removed. The render check's off-origin case is honest about being the negative half only — no comment card renders there, because the PR chain behind it is not scripted, and the file says so. The `useAnimatedScrollHandler` risk is answered by the two static facts rather than by a probe, and they are recorded as assertions: the hook is deliberately outside the four `MAPPER_HOOKS` because it is an event handler, and its updater's only effect is a write to `scrollOffsetY`, which `RightDrawer.tsx` assigns in two places and reads in none. A later read reds that case the moment it is added. The `oversized` stage-all refusal (ruling 2, made testable by ruling 5) was a silent no-op, and this is the fix as well as the case. Measured on this tree before it: `git.bulkStage` with 12,000 paths posts one 1,033,012-byte frame, the shell's reader drops it with `{ kind: 'refused', refusal: 'oversized' }`, and the page's promise never settles — `busyAction` never cleared and `setActionError` was never called. Both new cases red by timing out at 15s against that path. Refused at the page's own send boundary instead, under the shell reader's own predicate rather than a second spelling of it: `isBridgeFrameWithinCap` is extracted from `parseBridgeMessage` and used by both sides. `sendFrame` answers `sent` / `oversized` / `port-failed`, so `sendRequest` rejects with a `BridgeRequestOversizedError` whose message is a sentence the panel puts on its error surface, and the members whose contract is a boolean keep it. No delivery-unknown mark: the frame never left, so nothing ran on the desktop and the smaller retry is safe to offer. The case runs the real chain — bridge port pair, `useMobileGitRequests`, `runGitWorkflow` — with only react-native and the haptics seam mocked, and asserts the message that lands is a sentence and that the busy flag is raised and then cleared. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the type assertions from the two new C4.4 test files (OTA phase C, C4.4) The changed-code quality gate named five, all in the files the previous commit added, and a fence is not the answer to any of them. A separate commit because a reported head does not move by amend. - The comment fixture is a real `PRComment` rather than a cast: the type's six required fields are all this case needs, and the SAFETY disable that stood in for them was inert anyway — oxfmt had wrapped it onto three lines, and a wrapped `oxlint-disable-next-line` matches nothing. - The image lookup goes through `findAll` on the host tag rather than `findAllByType`, which takes a component. Through `String`, because `node.type` is `ElementType` and React Native declares no intrinsic elements, so the compiler reads a bare tag comparison as unreachable. - The runners hook takes its router from `useRouteHandoff` with expo-router mocked under it, which is how a `RouteHandoff` is obtained rather than asserted into existence. No target is pressed. - The rejection and the diagnostic are read through narrowings instead of casts, which also drops an `expect.any` that only type-checked because of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a frame the page cannot serialize inside the send contract (OTA phase C, C4.4 round 1) Round 1 finding 1. The oversized refusal moved `JSON.stringify` outside `sendFrame`'s `try`, so a frame carrying a cycle, a `BigInt` or a throwing `toJSON` threw past the whole send path. Three things followed, all measured here on a cyclic `params`: - the caller was rejected with a bare `TypeError` from `JSON.stringify` instead of the `BridgeSendFailedError` every other undelivered frame raises; - no `send-failed` diagnostic was raised, so nothing recorded that a frame had been lost; - `sendRequest` opens the id before it posts and abandons it on the way out, and the throw skipped the abandon: 63 of the 64 in-flight slots were usable afterwards, against 64 on a client that sent no such frame. Sixty-four of them and every later request is refused with nothing to say why. `posted()` carried the same escape into the members whose contract is a boolean, where a throw is worse still: those callers are taps and teardowns with no catch on them. Serialization goes back inside the `try`, with the oversized refusal kept in front of the post. The docstring said the port arm's throw is never `JSON.stringify`'s, which was exactly the assumption that broke; it now says why the call sits where it does. The new file is the pin: the rejection's name, the diagnostic, nothing reaching the shell, and the slot count with a no-cyclic-frame control beside it so the count cannot pass by the cap moving. Both changed cases red on the serialization moving back out — `expected 'TypeError' to be 'BridgeSendFailedError'` and `expected 63 to be 64`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the page's frame cap to the reader's, at the boundary and by construction (OTA phase C, C4.4 round 1) Round 1 finding 2. Nothing held the sender's predicate to the reader's. Replacing `isBridgeFrameWithinCap(json)` with an inline `json.length > BRIDGE_MAX_MESSAGE_BYTES + 1` passed 85 of the 86 mobile-web-shell and source-control test files on this tree, and a frame at exactly cap+1 would then be posted and silently dropped — the hang the refusal exists to end, back for every frame in that one-unit band. Two rules, because either alone passes against the defect: - The boundary. A frame of exactly the cap is posted, arrives at `parseBridgeMessage` and is accepted; a frame one byte over is refused with `BridgeRequestOversizedError`, posts nothing, and is the same string the reader answers `oversized` to. An off-by-one reds the second. - The census. The client reaches the cap through the shared predicate and does not name `BRIDGE_MAX_MESSAGE_BYTES` at all, and the module that exports the predicate is the module that parses inbound frames. A private copy that is correct on the day it is written reds here. The overhead the boundary frames are built from is itself checked rather than trusted: a frame asked for at exactly the cap must serialize to exactly the cap, so the constant cannot rot behind an envelope that grew a field. Against the mutation both new rules red — `expected null to be 'BridgeRequestOversizedError'` and the census failing to find the predicate — while the rest of the suite stays green, which is the finding reproduced. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): count the outbound frame in the unit both shells count it in (OTA phase C, C4.4 round 1) Round 1 finding 3. Read off both shells rather than assumed, and they agree: iOS gates the inbound frame on `json.utf8.count` (`MobileWebShellView.swift`, through `MobileWebShellBridge.acceptsByteCount`) and Android on `json.toByteArray(Charsets.UTF_8).size` (`MobileWebShellView.kt`, through `acceptsMobileWebShellBridgeByteCount`), both against `640 * 1024`. UTF-8 bytes on each platform. The predicate was already right. `isBridgeFrameWithinCap` decides on `utf8ByteLength`, and the `raw.length` clause in front of it is a cheap refusal in the safe direction, not a second rule: every code unit encodes to at least one byte, so a string over the cap in units is over it in bytes too. The diagnostic was not. It reported `json.length` — UTF-16 code units — in a field named `bytes`, so a frame of CJK text read as a quarter of the cap at the moment it was refused by it. It now reports `utf8ByteLength(json)`, and the type says which unit that is. Pinned with a 250,000-character frame of three-byte characters, which is under the cap in code units and over it in bytes, plus a source case reading the measuring expression out of each shell. Three mutations, all red: dropping the byte clause from the predicate reds the refusal (`expected null to be 'BridgeRequestOversizedError'`) and the diagnostic; reporting `json.length` again reds the diagnostic alone (`expected 250094 to be greater than 655360`), which is the defect this commit fixes, in the number it would have printed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): drop the render check's avatar assertion, which could not fail (OTA phase C, C4.4 round 1) Round 1 finding 4. The case asserted that no avatar host was requested while both routes painted, which reads as a proof of the web skip and is not one: no comment card renders on either page, because the PR chain the file's own closing note names is not scripted. Reproduced here — deleting the `Platform.OS !== 'web'` guard from `PRCommentCard` leaves the file at 5 passed. Deleted rather than propped up. Giving the page a presence precondition means five hand-written fixtures against five Zod schemas inside the shell double, which is exactly what the harness's docstring says that double must not become. So the only proof of that branch is `pr-comment-card-web-avatar.test.tsx`, which reds when the check is removed, and the render check now says so in its header instead of implying otherwise. What survives is a property of these two closures rather than of that component: not one request leaves the origin while either route paints, and nothing either paints violates the policy. That one can fail — planting a `fetch` to a provider host in a module both routes reach reds it twice, on the console-error case and on the off-origin case, with the `connect-src 'self'` refusal in the output. The CSP half is unchanged and was never in question: the served header is read from the Kotlin source and the Swift twin is pinned beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
8d42410e01 |
feat(mobile): render the HTML preview on the page in a sealed srcdoc frame (OTA phase C, C7.10 A) (#21862)
* feat(mobile): offer a cancelled top-frame navigation to the shell's opener Both shells cancelled every navigation off their own document in silence: iOS `decidePolicyFor` allowed only `isMainFrame && isDocumentUrl`, Android's `shouldOverrideUrlLoading` dropped anything whose resolved path was not "/". Nothing opened. That is the whole of ruling 29's "if they do not": a user tapping a link inside C7.10's sealed HTML-preview frame reaches the top frame as a navigation request, and the shell was the only thing that could act on it. A cancelled main-frame navigation now reaches JS as `onExternalNavigation` and goes through the same `Linking.openURL` the `externalLink` notify already uses. The scheme list is not restated natively: the native side caps the string and says which frame it came from, and `readBridgeExternalLinkUrl` decides what opens in the half that ships over the air. A subframe navigation is never offered, because that is the sealed preview loading itself. swiftc check: OK (`checkCancelledNavigation` added, the whole suite runs). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render the HTML preview in a sealed srcdoc frame on the page C7.6 gave the page the artifact's source, which is the native component's Source tab and half its job (ruling 8). Ruling 26 makes that debt: the Preview tab comes back as an `<iframe sandbox srcdoc>` inside the page's own document. `srcdoc` rather than a `blob:` URL, and no CSP change at all. Measured on Chromium and WebKit: a `srcdoc` frame has no URL for `frame-src` to match and inherits its embedder's policy instead, so it is admitted under the shipped `frame-src 'none'`, while a `blob:` frame is refused by `frame-src` on both and refused a second time in WebKit by the `frame-ancestors 'none'` it inherits. Two independent fences seal it, and the render check measures each on its own: the sandbox grants neither `allow-scripts` nor `allow-same-origin`, and the inherited `script-src 'self'` refuses the artifact's inline script even when a control arm grants `allow-scripts`. The inherited `img-src` and `font-src 'none'` govern its subresources, against a no-header control where the same three are fetched. `allow-top-navigation-by-user-activation` is the one token granted (ruling 29), so a tapped link becomes one top-frame navigation the shell now opens externally, while a `<meta refresh>`, a form submit, `target="_blank"` and any script-initiated navigation produce none. `lucideBarrelPlugin` is exported from the bundle builder so the check builds the toolbar's icons the way the page does rather than carrying a second shim. config/scripts suite, this file: 14 passed, 0 errors, exit 0. Control runs: a literal `sandbox` in the JSX reds 4, an added `allow-scripts` reds the script fence and the token census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's sealed frame where the degradation was pinned The three HTML-preview cases in this file described the state ruling 26 retires: no toggle, no frame, the source only. They now pin the frame's shape through the test renderer -- the artifact reaches it as `srcDoc`, the sandbox grants neither `allow-scripts` nor `allow-same-origin`, both toggle positions exist, and Source takes the frame away with it -- and the "never renders the html itself" case becomes "never puts it anywhere but the frame", counted rather than merely absent. What a browser does with that frame stays in the render check, which is the only thing that can answer it. The rich Markdown editor's half is unchanged: it is still the plain field, and item C is a later PR. Two mocks added: `Pressable`/`ScrollView` on the react-native double, because the toggle renders one, and `lucide-react-native`, whose barrel imports a `LucideProvider` its own context module does not export and so does not load under vitest at all. 9 passed, exit 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a link-activated top-frame navigation, even to the document F1, blocking, with F5 and F6 folded in because they are the same decision and splitting them would mean three rewrites of one function. F1: `<a href="/" target="_top">` and `href=""` in an artifact resolve against the embedder's base, so both named the shell's own document URL -- which both shells ALLOWED (iOS `isDocumentUrl`, Android's path `/`). One tap inside the sealed preview reloaded the shell's page: bridge target cleared, load state restarted, page state gone. A navigation a human started is now never allowed, whatever it names; it is offered instead, and `cancelledShellNavigationTarget` drops `orca-mobile-web:` in silence exactly as it drops `/h/other`. The page rewriting its own path carries no gesture and is still allowed. F5: the OFFER is gated on the same gesture, so a top-page meta refresh or a redirect is cancelled and never opened externally. F6: iOS returned early on `shouldPerformDownload` before the offer, so `<a download>` was dead on iOS and opened on Android. The early return goes; a download is refused rather than allowed when nothing started it, and a gesture-started one reaches the opener on both platforms. The allow half and the offer half are now one function per platform (`MobileWebShellNavigationPolicy.verdict`, `mobileWebShellNavigationVerdict`), so they cannot drift. The gesture is the platform's own answer: `.linkActivated` on iOS, `request.hasGesture()` on Android. Native tests, both platforms: document URL + gesture refused and offered; document URL without gesture allowed; foreign + gesture cancelled and offered; foreign without gesture cancelled and silent; download both ways; subframe never offered. swiftc OK; control run with the gesture rule removed exits 133. Gradle MobileWebShellDroppedNavigationTest tests=8 failures=0 errors=0. Also corrected: the screen comment that claimed the document's own reloads reach the handler (they never do), and the prop doc, which now states the gesture rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count own-origin top-frame navigations, and drop the goto cap F2: `page.setDefaultTimeout(4000)` capped `page.goto` at 4 s while every sibling render check uses the 30 s default, so under load the first WebKit cases redded on the navigation rather than on anything they assert. The cap goes; the per-action timeouts that needed to be short are already passed at their call sites. F1's page-side half: the rig now routes the page's own origin as well as the foreign one and counts main-frame navigations to each separately, with two cases pinning that `href="/"` and `href=""` each produce exactly one own-origin top-frame request. Playwright is not the shell, so what these state is the request the shell is handed; refusing it is the native tests' job and the docstring names which ones. The own-origin route is registered after the initial load, because it aborts main-frame navigations and the first `goto` is one. The foreign-tap and meta-refresh cases now also assert zero own-origin navigations, so a fix that merely moved the target would not pass. 16 passed, exit 0, no Errors line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait for the preview frame's own load, never a clock CI read the child frame before its srcdoc committed: frameUrl came back '' and the control arm's script as not yet run. The frame list, the frame's URL and anything read inside it settle at their own moments, and a 900 ms wait reads whichever of them has happened -- on a loaded runner, none. Polls for a child frame at about:srcdoc with its load fired, bounded by the case's own timeout, and an override arm now resolves on the document its srcdoc assignment commits rather than on the assignment. Red-first: with a 2.5 s mount delay standing in for a loaded runner, the paint case failed on both engines before this and all 16 cases pass after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say whose violations the preview rig reads The list is the main frame's: securitypolicyviolation does not cross into a frame, so an empty one says the embedder raised none and says nothing about the artifact's own style, image or font. A listener inside the frame cannot be the fix -- the fence under test is that nothing in the artifact runs. So the comment now claims what the reading supports, and names where the frame's containment is actually measured: the pixel for its inline style, the counting server for its img-src and font-src. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): announce which side of the preview toggle is showing The Preview/Source pair carried a label each and nothing else, so which one was showing lived only in the active background -- invisible to a screen reader on both surfaces. Each button is now a tab carrying its selected state, inside a tablist, and the two files' toolbars stay character-identical so the page and the phone announce the same thing. Red-first: the new case renders both siblings and failed on both for the missing role before this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the WebView mock like the file's other hosts The anti-slop gate refuses a bare `object` parameter. Takes the same shape as the react-native mocks beside it, which pass it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): allow only the load the shell itself started The document URL was allowed whenever the host reported no gesture, so a navigation the shell never asked for could reload the page out from under the session. Measured against a real WKWebView off-device: a sandboxed subframe navigating the top frame to the document URL arrives as `.other` with no gesture at all, and Chromium's own docs allow hasGesture() to be false for a request a human started. Census first: nothing in the page navigates the top frame -- no location assignment, reload, replace, window.open or form -- the router moves by pushState and replaceState only, so the rule needs no gesture and no page cooperation. Both shells now raise a flag around their own load and drop it at commit, and allow a main-frame navigation only while it is up. Everything else naming the document is refused and never offered, since offering it would send the user out of the app. iOS carries the second discriminator the same probe measured: sourceFrame is the main frame for the shell's own load and the subframe for a subframe's top navigation, so a subframe can never take the allow path. Red-first: the Swift checks and the Kotlin tests were written first and failed to compile against the old signature. 9 Kotlin tests, 54 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): point the meta-refresh arm at the embedder's own URL The fixture pointed off-origin, so its own-origin assertion could not move whatever the frame did. The new arm refreshes to `/`, which resolves against the embedder's base, and pins zero top-frame requests on a counter the `href="/"` case proves reads 1 in the same rig. It also counts what the frame asks for itself, with a presence control that attributes the fence: with `allow-same-origin` and no policy the same fixture navigates the frame to the embedder's `/`, and with the policy dropped but the product's token kept it navigates nothing, so the opaque origin is what refuses it rather than the CSP. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read what an action produced, not what a clock allowed The 600 ms after every action is gone. An arm that expects a navigation now returns the moment the route handler records it, with a deadline only so a click that missed its target says so instead of spending the case's timeout. An arm that expects none waits for two painted frames inside the page and one 200 ms drain for the popup queue, which is a browser-process event with no in-page counterpart; the docstring says why that one is bounded. Measured and reported rather than claimed: with the new wait replaced by a no-op every arm still passes, because the reads that follow are each a round trip. It is insurance against the runner load that produced the frame-commit race, not a fix for a failure seen here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the settling branch as a ternary What oxlint's prefer-ternary asks for, and the changed-code gate with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the preview frame by its element, not its URL CI timed out on all seven preview cases on one engine: the poll waited for a child frame whose URL reads about:srcdoc, and that browser reports an empty URL for a srcdoc frame, so every case ran to its own timeout. The same difference had already shown as `expected '' to be 'about:srcdoc'`. The frame is now the element: waitForSelector('iframe') then contentFrame(), with readiness taken from the fixture's own marker inside it. Nothing compares a frame URL any more -- the paint case reads the element's srcdoc attribute and the absence of src instead, which is what "parsed inside the frame rather than fetched into it" actually means. The one arm whose artifact navigates the frame away says so rather than waiting for a marker that is not coming. Red-first: with the old poll keyed on a URL the browser never reports, both engines time out exactly as CI did; the new wait passes 18/18 with the 2.5 s mount delay still injected. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make a frame that never becomes ready say what it saw The runner's Chrome read the preview frame's URL as empty where three chromium builds here read about:srcdoc: bundled headless, the headless shell, and --headless=old, all 147. So the difference is not reproducible locally and the next CI run has to carry its own diagnosis. The marker wait is bounded well inside the case timeout, and on expiry it reports the frame's URL, the srcdoc attribute's length and the page's CSP violation list -- which separates a frame the policy refused from one that was merely slow, the two readings that look identical from a timeout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): run the containment arms the comment only claimed The comment said the fixture navigates nothing with the policy dropped and the product's token kept, but no arm ran it: the control dropped both fences at once. Both single-fence arms exist now, either of which would hold. Measured rather than assumed, and one of them is not what the comment said. The token alone: the navigation never starts, no request, no violation. The policy alone, with allow-same-origin granted: the navigation does start and frame-src refuses it, which the embedder reports as its own violation. The engines differ only in what is left in the frame -- chromium an error page, WebKit the artifact -- so neither is asserted; what is asserted is that the request never reaches the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a download that names the shell's own document The document branch skipped downloads, so `<a href="/" download>` fell through to the offer path carrying the shell's own URL. Harmless in practice, because the opener's scheme list drops it, but it contradicted the policy's own comment and the prop doc, and it left the one URL that must never be offered reaching the boundary. The branch now covers a download too: refused, from either frame, gesture or not, and never offered. A gesture-started download of anything else still reaches the opener. Red-first on both platforms: the Swift checks exited 133 and the Kotlin row failed against the old policy. 10 navigation tests, 55 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the own-load flag wherever a document ends The flag lived beside the load call and had to remember every ending separately, so iOS missed two: a prop update that fails before it loads, and a renderer that died. Both left it raised, and a navigation to the document URL during that window would have been allowed. It now lives in the load state machine, which every ending already runs through -- a commit, a failure, a dead renderer, a prop update, a reset -- on both platforms, so there is nothing left to remember. The view raises it and reads it, and drops it nowhere. The Android residual is stated in the policy rather than papered over: between loadUrl raising the flag and onPageStarted dropping it, a navigation to the document URL from inside the preview frame would be allowed, because that callback says nothing about which frame asked and no host discriminator exists. It needs a generation switch and a tap in that window; iOS closes the same gap with sourceFrame. Red-first: the new Swift row failed to compile and the Kotlin row with it. 12 load-state tests, 56 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): spend the own-load flag on the allow, not on the commit The flag stayed raised from the load until didCommit, so a second main-frame action naming the document inside that window was allowed too and replaced the document. WebKit can decide a second action before the first one starts, so the commit is too late to be what spends it. The allow itself spends it now, before the decision goes back, and every ending still drops it for a load that is allowed and never commits. Red-first: the new check composes the machine with the policy -- the seam the flag and the rule meet at -- and failed to compile against the old machine. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop raising an own-load flag Android never consults WebViewClient's javadoc, verbatim: "This callback is not called for all page navigations. In particular, this is not called for navigations which the app initiated with loadUrl(): this callback would not serve a purpose in this case, because the app already knows about the navigation." So the flag guarded nothing on this platform and, while raised, was the one thing that could have let a competing request to the document URL through. The view passes isShellLoad = false always now, the machine drops the field it had no raiser for, and the policy comment carries the quote. Nothing reaching that callback is the shell's own load, so nothing naming the document is allowed there at all -- which also closes the generation-switch window the residual named, so that paragraph goes. No red to show: this is a removal, and the behaviour it leaves is the refusal the existing rows already pin. What a device proof must check is stated in the policy instead: a WebView that did route its own load here would have it refused and the load state would sit at loading. 55 tests in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): settle every arm, not only the ones that tap An arm with no action read its counters as soon as the frame's marker appeared, so a zero-delay meta refresh could dispatch after the reading. The arms that pin zero were the ones relying on it. Every arm settles now, and what it settles on is what it expects: the sealed refresh arms take the bounded no-navigation path, and the loose arm waits for a recorded navigation that is neither main-frame nor foreign -- its own frame's -- rather than the main-frame wait it would never satisfy. Red-first: with the settling removed and the refresh moved to 2 s, the loose arm reads 0 on both engines; with it back, 1 on both, the delay still in. A 0.4 s refresh passes either way, which is why the finding was invisible. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait for what the artifact's script wrote, not for the element The two-fences control asserts the inline script ran, and the marker element it waited for exists from parse time, so the arm could read window.__ran before the script had touched it. Under a loaded runner that reads 0, which is CI's "expected +0 to be 1" on chromium. Readiness is now per-arm: 'script' waits for the script's own write, 'load' for the arm whose artifact navigates the frame away, 'artifact' for the rest. Red-first: with the inline script's write delayed 1.5 s, the old arm fails on both engines with that exact message and the new one passes, delay still in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): bound the rig's waits by the case timeout and nothing else Two inner deadlines, 20 s and 15 s, were racing the outer one they sit inside, so a slow runner could fail a case on a number this file picked rather than on the one the case declares. Both now run to vitest's own `ctx.signal`, which aborts when the case times out. On abort the rig prints its reading -- the frame's URL, the srcdoc length, the violation list, or the navigations it did record -- and lets the case fail as the timeout it is. Nothing is rethrown from that path: a rejection raised after vitest has given up on a case has nobody left to catch it, and an unhandled one fails a run whose every test passed. Red-first: with the marker selector pointed at an element that never appears and the case timeout cut to 8 s, the diagnostic prints and the case fails as `Test timed out in 8000ms` rather than hanging in silence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): ask a stuck preview frame everything it can still answer The old diagnostic said only that a frame never parsed, and its violation list was the top document's -- securitypolicyviolation does not cross frames, so it said nothing about what the frame itself refused. It now prints the browser version, the arm it came from, the iframe element's srcdoc length and sandbox, contentDocument.readyState and contentWindow.href (which answer for a same-origin arm and report `refused` for an opaque one, so the arm's own origin is in the log), and every Playwright frame with its url, name, readyState, body length, marker presence, window.__ran and its own violations. Per frame, because the page's init script installs the collector in every frame -- measured on both engines -- and CDP evaluates inside an opaque frame whose scripts are blocked. Two corrections that the local probes forced. The reading is sampled while waiting and printed from the last sample: read at the abort it lost its race with vitest's teardown and printed nothing at all. And two arms had never been given the case's signal, so their waits could not be bounded or diagnosed. The diagnosis moves to its own module because the test file is at its line limit, and because the bound and the reading it prints are one thing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build a widened control frame instead of relaxing a live one A live frame cannot be relaxed. Sandbox flags are fixed on a browsing context when it is created, and Chrome 152 keeps the original ones through a srcdoc reassignment while still parsing the new document -- so the control arms that widened the product's own frame stayed sealed on the runner, and CI read a script that never ran and a refresh that never navigated. Chromium 147 here honours the relaxation, which is why it passed locally for a year of runs. The override now clones the element, sets the sandbox on the clone, gives it the artifact and replaces the product's frame with it, so the widened flags are there from creation -- the way the product does it, since React sets the attribute before insertion and never after. The product's own arms are untouched: a null override still returns immediately. And the control can no longer pass for the wrong reason on any engine. The header-keeping arm now reads the violation raised inside the frame: a script-src refusal can only happen if the sandbox let the script start, so it separates "the policy held" from "the frame was never widened", which the old arm could not. The loose arm pins an empty list beside it, the sealed arm pins an empty one too, and those three readings are the whole fence story. The violations come from each frame's own collector, because the embedder never sees them. Two diagnostic repairs the local probes forced: the browser version is read once at open, since asking at the abort printed "browser unknown" in the CI log this exists for, and the reading is sampled immediately as well as every five seconds, since a wait that only prints "no reading was taken" says nothing. Red-first: with the widening disabled, both engines fail exactly as CI did -- 180 s timeouts on the script arm -- and the diagnostic names the arm, the version and the sandbox it actually had. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put the toggle's selected state where a browser reads it CodeRabbit is right, and the browser says so: react-native-web's createDOMProps never reads accessibilityState, so on the page the tab pair emitted role="tab" and no aria-selected at all. The test renderer could not see it, because it reports the props the component was handed rather than the DOM they become. Both siblings carry aria-selected beside accessibilityState now -- the phone's screen reader takes the latter, the browser the former -- and the toolbars stay character-identical. Red-first, in a real browser on both engines: the rig now reads every [role="tab"] element's aria-selected before and after the tap, and it read null for both positions before this line existed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5d13a70ea3 |
fix(mobile): keep an in-page hop local only when the session's grants cover it (OTA phase C, C2.9) (#21723)
* feat(mobile): carry what each page route declared in init (OTA phase C, C2.9) The page decides an in-page hop from `init.pageRoutes`, which says which patterns this shell would render and nothing about what each one costs. So a push kept local on the strength of the pattern alone runs the target under the opener's grants — which is how the tasks page is reached from the wide-layout sidebar without `native.clipboard.write`, and why its copy actions refuse silently. `init` now also carries `pageRouteGrants`, the manifest's own route/grant pairs, from the manifest the shell already holds. Optional in both directions: an older shell omits it and an older page ignores it, and a page that receives none keeps today's rule. No new frame kind, no cap change, no protocol bump. The grammar is the manifest's, imported rather than restated (`MobileWebBundleGrantNameSchema`, now exported for this), so a grant name the bundle could not have declared cannot reach the page through this field either. The host validates the pairs before it builds the frame and refuses the session when they fail, for the reason it already refuses a malformed route: an `init` the page would reject whole is worse than no session at all. Two files were at their line ceiling and are split rather than bumped. The pairs schema moves to `bridge-page-route-grants.ts`, which is read by both the envelope and the host, so it belonged in one place anyway. In the session reducer the three sites that each spelled out "patterns, their grants, this route's grants" become one `routeViewOf`; that is a net reduction and removes the fourth spelling before it is written. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a hop local only when the session's grants cover it The rule the page was using is "the shell would render this pattern", and that is not the question. Grants are resolved once, from the route the shell opened, so a push kept local runs the target under the opener's list. On a wide layout the sidebar renders beside every `/h` route and pushes `/h/<id>/tasks` through this seam, so from the worktree list, agent history or the files pages the tasks page ran without `native.clipboard.write` and its copy actions refused with nothing on screen to say why. `servedHere` now means served here *and* covered: the target's declared grants must be a subset of this session's. An uncovered page route is handed to the shell exactly like a non-page route, and the shell opens it as its own session with its own grants — which is the mechanism that already exists, rather than a new one. Three answers, not two, because an absent field is not an empty one. A shell that sent no pairs keeps the old behaviour: `null` is "nobody told me", and an older shell has to keep working. A target the shell lists but names no entry for is *not* covered — the page cannot justify that hop, so it hands it over rather than guessing in the direction that loses grants. This is C3.1's explorer ⊇ preview finding without its pairwise pin: that hop is covered by this rule and stays local, and the rule scales to the sidebar, which reaches every route and which no pairwise list can keep up with. Red first on the two cases only the new rule answers; the other four are the regression guards and passed before and after. Two whole-session assertions gained `pageRouteGrants: null`, which is what the reader now returns. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the sidebar hop in a browser, under the session's own grants The unit tests pin the decision; only a browser shows the control exists, is reachable at the viewport where the sidebar renders, and that the document does not move when the hop is handed over. Four cases on the shared harness, which now forwards `pageRouteGrants` (omitted when a caller names none, because an absent field is not an empty one and the page reads the difference). - Wide, session without `native.clipboard.write`: tapping Tasks posts exactly one `navigate` notify, the document stays on the worktree list, and **no new chunk is fetched** — which is what says the page did not quietly render tasks under the wrong grants. - Wide, same tap with the grant added: no notify, the document moves to `/tasks`. Without this the first case would pass on a page that simply never navigates. - Wide, shell sending no pairs at all: the old behaviour, local. An older shell must not start handing every hop over on a field nobody sent. - Narrow: asserts the absence rather than a tap. `app/h/_layout.tsx` renders the sidebar only on a wide layout, and only that header branch labels its Accounts and Tasks controls; the narrow header's are unlabelled pressables. So the hop does not exist at that viewport, and `getByLabel('Tasks')` finding nothing is the honest assertion. That unlabelled narrow header is a real accessibility gap and is not this lane's to fix. Registered in `pr.yml`'s `mobile_web_app` job beside the other render checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the in-page hops a session's grants cannot cover The rule landed in the commit before this one decides each hop; this says which hops those are, so a route's grants growing — or a new push between two page routes — shows up here rather than as a verb that silently refuses on a device. Openers are every page route, not the one that happens to push. On a wide layout `app/h/_layout.tsx` renders the worktree-list sidebar beside every `/h` route and its header pushes tasks, which is exactly why a pairwise pin is the wrong shape: the sidebar reaches everything, so the census has to be the cross product of what the manifest declares against what the source actually builds. Targets come from the hrefs the app builds, read out of `mobile/src` and `mobile/app` and reduced to route patterns, so a hop nobody writes is not pinned and a hop someone adds is. A presence case asserts the sidebar's tasks push is among them, because a census that stopped finding hops would go quietly green. Two hops are pinned as handed off today, both into tasks, which is the only route declaring more than `navigate` and `storage`. A third case asserts the other half of the rule on the manifest: a target asking for no more than its opener stays in the document. Checked that it discriminates rather than assuming: widening the worktree list's grants to cover tasks fails the pin, and restoring them passes it. **No pin was deleted.** The brief expected C3.1's pairwise explorer/preview pin to be replaced here, but C3.1 is not on this base — `MOBILE_WEB_PAGE_ROUTES` has three routes and no `files` entry, so there is nothing to remove. When C3.1 lands, its pin is this census's to subsume. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an unused import from the hop census `statSync` was imported and never used; `oxlint` fails it. My error: I committed the census on a green test run without waiting for lint, the same order mistake I made earlier in this lane. Fixed forward rather than amended, because the lane forbids rewriting a commit that exists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fold C3.1's pairwise grant pin into the hop census C3.1 landed while this branch was open, and it brought the case this lane generalises: the explorer pushes to its own preview, that push stays in the document, so the preview runs under the explorer's grants. Its pin asserted that one pair by name. The census now covers it as a consequence rather than a rule. With the files routes in the manifest the cross product finds six more hops the session cannot cover — the sidebar into files from the worktree list and from agent history, and both files routes into tasks — and it does **not** find explorer → preview, because the preview declares no more than the explorer. That absence is the pairwise pin, derived. So the pairwise block is deleted, with its import. The rest of that file stays: its external-link seam checks and its clipboard-absence control are about what the files closure contains, which this census says nothing about. Checked the extended census still discriminates: granting the explorer `native.clipboard.write` fails the pin, restoring it passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the sidebar hop from a files route, not only the worktree list The defect is not "the worktree list pushes tasks". On a wide layout the sidebar renders beside every `/h` route, so the same hop exists from the files explorer, whose session carries `externalLink` but not `native.clipboard.write`. One opener proving the rule would have left the general case to inference, which is the inference C3.1's pairwise pin already made once. Opened on `/h/<id>/files/<wt>` with the files route's own grants, the sidebar's Tasks control posts exactly one `navigate` notify, the document stays on the files route, and no new chunk is fetched. The harness helper now takes the route and the text to wait for, so a case can open on something other than the worktree list without a second copy of it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the render helper wait on the text its caller named The `awaitText` parameter I added in the commit before this one was never wired into the wait, so it was dead and `oxlint` failed it. The case still passed, because the files route renders the host name in its sidebar and that is what the helper was still waiting on — which is exactly the kind of accident a dead parameter hides. Third time in this lane I have committed on a green test run before lint finished. Fixed forward, not amended. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): carry route grants through the download path `onManifestRead`'s download branch set `pageRoutes` and `routeGrants` from the new manifest and dropped `pageRouteGrants`; nothing downstream recomputes it, so every first install and every OTA update reached `ready` with the default or the previous generation's pairs. The page then read each target as listed-with-no- entry and handed off every in-page hop. `routeViewOf` moves to `page-route-policy.ts`, beside the two functions it calls, to keep the reducer under its line cap without a bump; its stale neighbouring comment, which described a filter that moved into it, goes. Red first: the cold-cache and generation-change cases failed, the cached-hit case already passed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): derive census targets from navigation call sites The reachability filter was inert. Harvesting every `/h/${…}` template caught the five screens that declare their own mount pathname, two `pathname ===` comparisons and the route template types, so every declared route was reachable through its own mount: the pinned table was the all-pairs one, eight hops with the filter and eight without. Targets now come from the arguments of `router`/`navigation` `push`, `replace` and `navigate`, and of `navigateFromHostList`; mounts, comparisons and types are excluded by construction because they are not navigation arguments. Two real hops are not written as a literal, so a local binding or a call is followed one step to the function that returns the pathname: the files explorer is pushed as `{ pathname: descriptor.pathname }` and the preview as `push(createMobileFilePreviewHref(...))`. A call site whose target cannot be read is returned rather than dropped. Derived patterns go from 11 to 10; the pinned table stays at eight because all five page routes are genuinely pushed to. What changes is that the filter now discriminates: deleting the header's two tasks pushes reds the presence case and drops the four `-> tasks` rows from the pin, where the old derivation stayed green on the same deletion because `app/h/[hostId]/tasks.tsx` still declared the pathname. A push added at a real call site appears in the set. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): restore the preview-declares-something guard The pairwise pin this case replaced asserted the preview declares at least one grant before asserting the explorer covers them all; without it two empty lists satisfy the subset check and a route that lost its grants passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): describe the route list under the handoff rule Two passages described the world before this PR: the explorer's note said the census pins its pair with the preview, and a closing paragraph left the sidebar's tasks hop open for a later PR. This is that PR. Covering the preview now buys the in-document hop rather than making it correct, an uncovered target is handed to the shell and reopened under its own grants, and the census reads the explorer to preview relation off this list rather than pinning it by name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mirror the manifest's tasks grants in both fixtures CodeRabbit on #21723: both fixtures declared the tasks route as `navigate`, `storage`, `native.clipboard.write` while the manifest also declares `externalLink`, so no covered-session case ever required it. Both now mirror the manifest's four, and the covered sessions hold them. That alone does not make an `externalLink`-blind rule fail, since those sessions hold every grant either way, so the unit suite gains the case that does: a session holding the clipboard but not `externalLink` must still hand the hop off. Mutating the rule to treat `externalLink` as always held reds that one case and no other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make a stalled hop name its own cause Both waits for the hop to land read as a bare 30 s timeout when it does not. The CI failure that sent this file back was a `TypeError` inside React Navigation that blanked the document, and it was invisible here because the error assertions run after a wait that never returns. The wait now throws with the page's own account: the pathname it stayed on, the collected page and console errors, the `navigate` notifies posted, the first 300 characters of the body, and every `.js` response since the click with its status. The response listener records every script answer rather than only the 200s, so a chunk the navigation waits on can be seen failing; the 200-only list the no-new-chunk assertions read is unchanged, as is everything the five cases assert. Kept in this file because no other render file waits on the pathname moving. Proved by mutating the rule to hand every hop off: the covered case fails naming the pathname it stayed on, an empty error list, the notify it posted and no scripts since the click — which is the handoff signature, distinct from the crash signature CI saw. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): aim the narrow hop at the control C2.10 named The narrow case asserted the absence of a labelled Tasks control, which was true only because the narrow toolbar carried no accessibility props. C2.10 gave it the wide sibling's role and label, so the assertion was red on the merge and, worse, the rule this file is about went unproven on the branch the phone actually presses. It taps that control now: at 390 px there is exactly one, and the tap posts exactly one navigate notify for the tasks route while the document stays on the worktree list and fetches no new chunk. Red first against the merged header (count 1, expected 0); with the session given native.clipboard.write the hop goes local and the case reds, which is what says the assertions discriminate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop the handoff predicate's contradicted one-liner The pre-C2.9 summary said the answer is whether this document renders the target, which is exactly the claim the block comment below it replaced: the predicate now also requires the target's grants to be covered. Two doc comments on one declaration, the first of them wrong. Comment only; the 35 handoff cases are unchanged and green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert which field a route refusal blames The host builds `pageRouteGrants: <issue>` so a refusal says which of the two checked inputs failed, and nothing read it: the case counted refusals, so a host that reported the route's own verdict for a malformed pair would have stayed green while sending whoever reads the refusal to a pathname that was never the problem. The case pins the prefix, a non-empty issue behind it, and that the diagnostic and the callback carry the same string. The control is an opener that fails the other way: a malformed route reports its own issue and does not take this prefix, without which the pin would hold on any reason at all. Red first with the field branch dropped from the reason: the prefix assertion fails and the control stays green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop exporting the route filter the reducer stopped calling `implementedPageRouteEntries` and `implementedPageRoutes` were the reducer's two ways in before it moved to `routeViewOf`. The entries form had no caller anywhere afterwards and the patterns form had only this test, so the module's public surface advertised two functions no product code reaches. Both are module-local now; the surface is `matchesRoutePattern`, `pageRendersRoute`, `grantsForRoute`, `routeViewOf` and the grant list. The test reads the same list through `routeViewOf(...).pageRoutes`, which is the reducer's own view of it, so no assertion changed and no export is kept for a test. Red first: with both un-exported and the test untouched, seven cases fail with `implementedPageRoutes is not a function`; routed through the view all nineteen pass. Still discriminating, as a control: with the grant filter dropped from the entries helper, four of them fail. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): route the merged haptics cases through the policy view PR E's two haptics cases arrived with the merge calling `implementedPageRoutes`, which this branch had already made module-local, so the merged file was red with `implementedPageRoutes is not defined` on both of them. They read the same list through `pageRoutesOf`, the view the rest of the file already uses, so neither assertion changes. PR E's paragraph named that function for the filter it describes; the filter now sits in the entries helper the view is built on, so the sentence says that instead of naming a function the reader cannot see. Red: the two cases above on the merge. Green: all 21, PR E's two included. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mirror the haptics token in every handoff fixture PR E put `haptics` on all five manifest routes, and these fixtures still carried the pre-E grant lists: tasks with four grants where the manifest now declares five. A fixture that is short the same token on both sides of the subset check agrees with the rule by accident, and would have gone on agreeing after the token stopped being universal. The pairs mirror the manifest now, and each session carries what its opener route would actually be granted, since the host narrows a route's declared grants to what the shell implements and the shell implements the token. Red first, with the token added to the pairs alone: the two covered-hop cases flip to handed-off, `stays in this document when the session already covers the target` and `keeps the hop in the document when the session covers tasks`. Green once the sessions carry it, 35 and 5. The hop census needed nothing: it reads `MOBILE_WEB_PAGE_ROUTES` itself. Measured there, all 5 routes declare the token and it is the missing grant in 0 of the 8 uncovered pairs, so it cannot decide a hop and the rule still reads only `pageRouteGrants`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): count C2.9's two bridge modules in the session route closure #21908 recorded this pin at 4,324 for the haptics notify module. C2.9 adds two more that the same closure reaches: the page-route-grants schema and the manifest contract whose grant grammar it imports rather than restates, both pulled in by `bridge-envelope.ts`, which the page reads to parse `init`. Named in the docstring beside #21908's sentence rather than folded into its number, because the three modules arrived from two PRs and a single count with one reason invites the next author to assume the rest. Red first against 4,324: expected 4,326. Measured on this head, not inferred -- a control worktree at pristine main gives 4,324, so the two are this branch's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e9b180685b |
feat(mobile): render Mermaid diagrams on the page from one deferred engine artifact (OTA phase C, C7.10 B) (#21871)
* test(mobile): measure mermaid rendered in the page Red-first for C7.10 item B. The check mounts the real web sibling in chromium and webkit under the shipped shell CSP and asks four things of it: that a diagram renders with zero policy violations and zero eval / new Function calls, that the SVG is the native buildHtml's own output once the diagram id and xmlns:xlink are normalised away, that a hostile diagram lands inert, and that a source change, an unmount and a remount leave exactly one SVG and no listener of the first mount. The equality oracle is buildHtml itself, bundled for Node behind a Proxy stub for its native imports and served as its own document in the same browser, so neither side of the comparison is retyped. All eight cases fail on this commit: the sibling is still the labelled source box. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fence the session download rather than its module list Ruling 28. mobileWebAppRouteClosure reads metafile.inputs, which holds dynamically imported modules under splitting: true exactly as it does under splitting: false, so it cannot say "on demand" about anything: an on-demand mermaid moves the session route's module list 4320 -> 6362 while its download does not move at all. So the fence moves to entryStaticClosure. The new helper walks the emitted chunks from the output the route's own module landed in and follows import-statement edges only, and hands back both halves, because mermaid's absence from the download is only a measurement while its 66 files are present in the deferred half. The module list's new total is recorded in the docstring with its reason and asserted beside the engine's own file count, which moves only when the pinned mermaid version does. Red on this commit: no mermaid in the closure yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render mermaid in the page The web sibling stops being a source box. mermaid is a browser library, so the page imports it inside the render effect and draws the diagram in this document: no WebView, no 3.7 MB engine string, and nothing of the engine downloaded by a session with no diagram on it. What replaces the sandbox is mermaid's own securityLevel: 'strict', which runs its serialized SVG through DOMPurify. The native path's </script> escaping has no analogue here and needs none, because the source is a JS string argument rather than text spliced into an inline script. Measured in both engines: a script in a label, a </script>, an onerror and a javascript: click all land inert. The configuration is now one object both hosts read, so the theme cannot drift between the page and the phone; buildHtml serializes it instead of holding a second copy. It gains suppressErrorRendering, because mermaid otherwise draws its own error diagram into a temporary element and leaves that element behind when it rethrows -- an orphan SVG on the page, and on native a diagram the component is about to replace with the source box anyway. The dispose clears the host on unmount and on a source change; the id is a useId, because mermaid writes it into the stylesheet inside the SVG and it has to be a CSS identifier. Also re-records the closure total the previous commit pinned: with the real component the session route's module list is 6376, not the design probe's 6362, and the reason is in that file's docstring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): budget the deferred engine's chunks apart from the routes Putting mermaid on the page took the app bundle from 69 emitted scripts to 172, and the asset budget failed: 215 assets against a ceiling of 115. The cause is not a page split running away, which is what that ceiling is for -- it is that mermaid lazily imports each of its own diagram types, so one import() lands 103 scripts no route count predicts. So the ceiling gains a second term, named and measured (172 scripts with mermaid against 69 with it aliased to a stub, at 11.17.2), rather than the route term being raised to cover it. A page split running away still fails on the route term, and the failure still says which of the two grew. The consequence is worth reading twice: the derived ceiling has to stay inside the 256 assets the shell will load, and with 42 images it now crosses that at 24 routes instead of 50. The bundle is at 215 today with 14 routes, so there is room for about ten more routes before a green build produces a manifest no phone will open. Measured by the config/scripts suite failing on this head, not predicted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): pre-bundle the page's mermaid into one artifact import('mermaid') from inside the app bundle emitted 103 scripts, not one: mermaid lazily imports each of its own diagram types and esbuild splits along those boundaries. Every one of those scripts sits inside the OTA generation the phone has already downloaded, so the split moved no bytes over the wire and spent 103 of the 256 manifest assets the shell will load -- which is the scarce resource here, and the reason the previous commit had to invent a second ceiling term. So a sibling generator bundles the package into one ESM module beside the WebView engine it already builds, emitted by the same postinstall run, gitignored and lint-ignored with the others. The page imports that artifact on demand instead, through a loader whose return type names the two calls the component makes -- checked against the artifact's own inferred export rather than cast to it. Measured, at 14 routes: emitted scripts 172 -> 69 (68 with no deferred engine at all) manifest assets 215 -> 112 (111 with none) session modules 6376 -> 4323 (+3 over main: config, loader, artifact) chunks fetched for one graph TD 27 -> 1 bytes fetched 837,530 -> 3,482,965 The static-closure fence is unchanged in meaning and now reads on the artifact: absent from every chunk the route reaches by an import statement, present in the deferred half. The rendered SVG is byte-for- byte what it was, so the equality against the native document still holds on both engines. Also adds the diagram to the webview-consumers list, which is what that list means: its native component imports the package and its sibling is what the builder resolves instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * revert(mobile): drop the deferred-engine ceiling term, keep the control With the engine pre-bundled into one artifact the bundle emits 69 scripts at 14 routes against the route term's 72, so the second term this series added has nothing left to do and the route count is the only term again. mobileWebAppBundleMaxChunks and the asset ceiling derived from it are back to what main has; the shell's 256 assets are crossed at 50 routes again rather than at 24. What stays is why. A ceiling raised to admit 172 scripts would have admitted any split at all, so the budget test gains the control that holds the line: the single-artifact count passes the ceiling and the lazily-chunked count fails it, both measured at 14 routes, with mermaid named as what produced the second. Red before the term came out: the control failed asserting 172 > 175. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep build output out of the raw-request-port census The census walks mobile/src for AST reaches into the unvalidated request port, and the pre-bundled mermaid artifact is the first generated file under src that is executable code rather than a string literal. Two of its own vendored dependencies contain the token `sendRequest`, so the walk read minified third-party code as a new call site and asked for an inventory line nobody can ever migrate. So `*.generated.ts` joins node_modules and test files in that file's stated list of what it does not scan, with the reason. The scripts that emit those artifacts are ordinary source and are still scanned, which is where a real reach would be. Two halves to the new control, because a filter that skipped everything would satisfy either alone: nothing generated is left in the scan, and the matcher still finds the port when handed one line of code. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): escape the shared config into the native inline script buildHtml spliced JSON.stringify(MERMAID_DIAGRAM_CONFIG) straight into the inline <script>, twenty lines below the function that exists because JSON.stringify leaves `<`, `>`, `&` and the U+2028/9 separators raw. Inert at today's five hex colours, and not inert for a themeCSS or a font stack, which is free text going into the same script element. So the escaping splits from the stringify and both callers use it: the source keeps its own wrapper, the config gets one. Those characters only ever appear inside JSON string literals, so escaping them is valid for an object serialization exactly as it is for a string. Red first: a config carrying `</script><script>` put four raw closers in the document where a benign build has two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the page mermaid type against the package's own The loader returned the artifact's default as PageMermaid, which checked that two names exist and nothing about their shapes: the artifact is minified vendor output and both members infer as `any` there -- a probe assigning engine.render to a number compiles -- and `any` satisfies every signature there is. So the shapes are asserted against the package's `Mermaid`, which is precise. A PageMermaid member whose signature the engine does not really have now fails at this line rather than at a call the page makes. In the product module, not a test: mobile/tsconfig.json excludes test files, so a type-only assertion in one is never compiled. Underscored because it is a compile-time statement with no runtime reader, which is the form the linter asks for. Control, verified both ways: changing render to (id: number) => Promise<{ svg: number }> reds tsc naming both parameter and return, and the real signatures compile. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): re-measure the chunk series and say what it does not show The four-point series was stale and read as a slope it is not. Measured again on this head, by copying the route tree and dropping routes from the end of the sorted key list -- both siblings of each, because deleting a .web.tsx alone leaves the native file for the builder to resolve and measures an entirely different closure, which is how the first attempt at this produced 77 scripts for 14 routes: 8 routes -> 32 scripts 10 routes -> 43 12 routes -> 61 14 routes -> 69 (the real tree) Between four and nine more per route depending on which route, so 4r + 16 is a bound and not a fit, and the justification now says that instead of claiming three per route. It also says the part that matters more: at 14 routes the tree measures 69 against 72, and the last two routes cost the 8 the ceiling grants for two. The fence is at break-even, and the new assertion states that slope from the function rather than from a comment. Also records what the generation weighs, since every chunk ships in it whether or not a phone fetches one: 8,016,714 bytes across 112 assets against the 9 MiB ceiling, 84.9%, 1,420,470 left. It was 4,539,090 before item B, and the engine is the difference. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the native fallback under suppressErrorRendering The shared config reaches the phone too, and it gained a key the native path did not have. So the native document is now loaded for a diagram that throws, in both engines, with window.ReactNativeWebView standing in for the host: mermaid's run still rethrows, the document's own catch still posts `error`, and that is the message the component turns into the source box. Measured both ways, so the case says which half the key owns. Whether the fallback fires does not depend on it -- `error` is posted with the key and without it. What depends on it is that nothing is drawn behind the fallback: removing the key leaves mermaid's own error diagram in the document and reds this case at 1 SVG against 0, on chromium and webkit alike. The control is the same document for a diagram that parses: a height, not `error`, and one SVG. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one walk for every source census, without build output Nine censuses under mobile/src each held a copy of the same recursive walk, and each decided for itself what a source file is: seven had no opinion about generated files, one excluded them in its own regex, and one had the exclusion I added last round. So all nine read 7.9 MB of emitted vendor code -- 3.7 MB of mermaid for the WebView, 3.5 MB of it for the page -- and the two largest censuses TypeScript-parsed all of it, looking for call sites nobody wrote and nobody can move. That is what took rpc-params-contract-type-only-boundary over its 5 s timeout in CI once the fifth artifact arrived. Measured here, median of 3, import plus tests: main, 4 artifacts, no exclusion 1004 ms (slowest case 831 ms) with the 5th, no exclusion 1513 ms (slowest case 1358 ms) with the 5th, this commit 947 ms (slowest case 788 ms) So it lands below where main has it, not merely below where I left it. Across the nine, four more halve: rpc-operation-cast-fence 769 -> 441, rpc-subscription-boundary 946 -> 468, unchecked-rpc-reader-boundary 1042 -> 538, lifecycle-owner 747 -> 433, reanimated-web-mapper-deps 1028 -> 516. The two that already excluded generated files do not move. What each census counts as interesting -- extensions, whether test files are in -- stays its own, because they genuinely disagree. What counts as a source file at all is now said once. The control is the file that started it: a *.generated.ts whose text holds exactly the import a census is hunting, planted beside an ordinary file carrying the same text. The generated one is not returned and the ordinary one is, so the absence is a measurement. A second control reads mobile/.gitignore and holds the predicate to every artifact the tree generates, and a third fences the walk itself to one spelling, so a tenth census cannot paste the cost back in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct why the type pin sits in the product module The comment said a type-only pin in a test file "is never compiled". That is false: mobile/tsconfig.json excludes *.test.ts, but tsconfig.test.json is a second program that does check them, run by check:tests-typecheck and held by the tests-typecheck ratchet. The conclusion is unchanged and the reason is now the true one. The app's own typecheck is the unconditional gate and would not cover a pin written in a test; the test program is real but carries a grandfathered baseline and a few files held outside it on purpose. And the assertion is about this module's own type either way, so it belongs beside it. Comment only; tsc, the ratchet and both lints re-run on the file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
c77a82f783 |
feat(mobile): fire the page's haptics over the bridge notify (OTA phase C, C7.10 E) (#21864)
* feat(mobile): give the page a haptics notify and the grant that gates it `native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly the five `src/platform/haptics.ts` has, and the single token `haptics` joins `BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb because nothing is owed back: a reply would spend a slot in the same 64-deep in-flight window a forwarded request does, and there are 90 call sites in this app, some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30). The arm's fields live in their own module because `bridge-envelope.ts` is at its line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays in the envelope, so the fields are spread in beside it rather than reading it back through an import cycle. The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other device-local notify does: the host is the protocol's side of the bridge and a static import of the app's haptics would put `react-native` and `expo-haptics` in its graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping — `haptics.ts`'s own functions, its `Platform.OS` split and its Android `HapticFeedbackConstants` untouched. The dispatch branch rides along with the union rather than waiting for the page side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over that union, so the shell does not compile without it. That is the totality working, and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is. Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin and the missing-row type error all failed against the tree before this. Control on the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): post the page's haptics over the notify instead of doing nothing `haptics.web.ts` stops being five no-ops. Each of the five posts its own kind through the notify seam the entry publishes — the same shape `publishExternalLinkOpener` has, and for the same reason: every caller is a plain function inside a row's press handler that no provider wraps. `notifyHaptics` joins the page client beside the other gated notifies and answers whether the frame left, which nothing reads: a tap that did not buzz is what the page did before this, and a warning per refusal would be one per row of a scrolling list. Measured off the frame the client posted rather than a written copy of its shape, which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection / success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`, and a twelve-row scroll 888 bytes across twelve frames. The `web-overrides.json` reason now says what the file does instead of what it declines to do. Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a function`, and the six client cases on `notifyHaptics is not a function`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): grant haptics on every page route, with a census that derives the list All five declared page routes carry the `haptics` grant, and the list is a measurement rather than a hand choice: `WorktreeListRow` is in every page closure and calls the seam, so a route without the grant is a page whose taps stop buzzing with nothing on screen to say why. Grants are resolved once from the route the shell opened and held for the session, so the declaration is the only place to fix it. `mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one: it reads the kinds off the tuple that declares them, finds every exported `trigger…` function in a haptics module, and reports the kind each one posts. The posting call is found through the binding `publishHapticsNotifier` assigns rather than a local spelled `post`, because a rename would otherwise turn every posting site into a non-posting one and leave this green on a page with no haptics at all. The census proper holds each route's closure to the `.web.ts` sibling, asserts at least one importer so the grant is not idle, and derives the granted-route list from the closures. The control is the design's: the same walk over the native sibling finds the same five functions and no posting site, so "all five post" is a number rather than an empty scan. Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post reds 1 of 23. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record what the haptics notify costs a page closure One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it arrives through `page-route-policy.ts` reading the grant token rather than through the seam, whose import of the kind type is erased; its only dependency is `zod`, which the envelope already put in every closure, so the module total moved by the same one. Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336. Pinned structurally rather than as a total, because an absolute closure count is main's to move and a number that drifts for unrelated reasons is one nobody reads. The call sites this replaces, measured over product modules: `triggerError` 43, `triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10, `triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus `page-haptics.ts` itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): carry the haptics grant into the shell's two grant pins `bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over and reaching the real module pulls in an Expo runtime this test does not have, which failed the whole suite at import. Which expo member each kind reaches stays in `page-haptics.test.ts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): map each haptic kind to a named import, not a namespace index The changed-code gate refuses a computed reference into an imported namespace, in both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()` is a call nothing can follow. Each function is a named import instead, which also keeps the second compile-time direction — a row naming something `haptics.ts` does not export is now an import error rather than a `keyof` mismatch. The third direction moves with it, from a namespace read in the test to the census that already reads both files' text: `hapticsImportedNames` names what the shell's mapping takes from the app's haptics, and the census holds that to the five the native file exports. So a haptic added there with no kind of its own still fails, and now it fails where the other two siblings' names are already compared. The test's two `as` assertions become one annotated hoisted type, the shape `MobileWebShellScreen.test.tsx` uses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the true reason the haptics grant is one token `GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>` with lowercase segments, which is why it rejected `native.media.readChunk` and rejects `navigate-back`, not a dotted name as such. So four comments claiming a route declaring the notify's own name would have its bundle refused were false, and they are gone: the grant is a token because the notify table's grants are tokens, a notify not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from the verb table alone. Also folded, with the false claim: `implementedPageRoutes` filters on `grants.every(implementsGrant)`, so a token every page route declares couples the whole set to a shell that carries it — against one without it, no page route is served at all and the phone renders five native screens. Stated in the function's docstring and beside the census's derived list, and pinned: the same declaration under a grant this build does not implement comes back empty, with the token-free route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS` reds that case. `%#` consumes no argument, so the web seam's five cases were titled with the whole function body; the kind is the first element now and `%s` names it. One 110-char comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two still over it there are main's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
bd5177801b |
feat(mobile): put the page's pickers, paste and editor fallbacks on the media verbs (OTA phase C, C7.6) (#21795)
* feat(mobile): put media picking behind a platform seam (OTA phase C, C7.6) The session screen picks images three ways — the photo library, Files, and the pasteboard — and all three are native modules a page cannot import: the codegen lookup `expo-image-picker` and `expo-document-picker` run at import throws in a browser, and the route manifest imports every route, so one of them in a page closure is the whole bundle down rather than one picker. `src/platform/media-picker.ts` is the phone's, delegating to the same three calls the screen already made. `.web.ts` is the page's: `native.media.pick`, then `read` in order to `eof`, then `release` for every handle it was handed, including the ones its caller never took — the shell holds eight staged files at a time and an abandoned pick otherwise waits out the five-minute TTL. A refusal rejects with the shell's code on it and is never folded into the empty answer that means the user cancelled. The bytes are concatenated decoded and encoded once, because the wire promises `eof` and nothing about the length: a shell answering a range shorter than the one asked for ends a chunk on a partial base64 group, and a reader joining the strings would fold that padding into the middle of the file. The census walks the session route module's own closure — the route is not registered until C7.7 — and names any module that reaches a picker or `Clipboard.getImageAsync` directly. Today that is the two modules C7.6's next commit moves, listed by name so the list goes empty rather than the rule going quiet. Inert: nothing calls the seam yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): put the session's paste and attach on the media seam (OTA phase C, C7.6) The terminal paste read the pasteboard through `expo-clipboard` directly and the two attach paths called `pickMobileImage`/`pickMobileImages`, so the page's closure carried `expo-image-picker` and `expo-document-picker` — native modules whose import throws in a browser. All three now go through the seam. Text is `native.clipboard.read` on the page, which the clipboard seam gains a reader for: `expo-clipboard` resolves to `navigator.clipboard` there, which needs a secure context the iOS shell's custom scheme is not. An image is `pick { source: 'clipboard' }` rather than an inline value, because a clipboard image is 24 MiB of base64 against an 8 MiB reply ceiling. The census over the session closure is empty now and asserts the seam is in it, so a rule that found nothing is one that had something to find: with the three call sites restored it names all three. `mobile-image-source-picker.ts` stays the phone's implementation, reached only through the seam's native sibling, and resolves out of the web closure entirely. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): resize a clipboard image on the page with a canvas (OTA phase C, C7.6) The paste hook carried the raster shrink inline over `expo-image-manipulator` and two `expo-file-system` writes. Both are native: the manipulator has no browser build, and the temp file exists only to work around an iOS loader that cannot decode a large base64 data URI, which a browser does not need. Split into `mobile-clipboard-image-resize.ts`, unchanged, and a `.web.ts` that decodes one `<img>` from the data URL the shell's `img-src 'self' data:` already admits, draws it into a canvas at the target size and reads the PNG back out of `toDataURL`. It reports the canvas's own size rather than the size asked for, because a browser clamps a canvas past its area limit and the downscale loop above would otherwise retry a raster that never shrank; and it awaits `decode()` rather than `onload`, which never fires for a source the browser cannot read and would leave the paste waiting on a promise nothing settles. Measured in Chromium under the shipped header, on a noise PNG because that is what PNG compresses least: 1400x1000 encodes to 5,476,032 base64 characters and converges in one pass to 368x263 and 397,220, which is 75.8% of the upload path's 512 KiB chunk. Zero policy violations and zero page errors. Red under three mutations: the source returned unchanged, a reported size the canvas did not draw, and `onload` in place of `decode()`. `computeMobileClipboardImageDownscale` moves to a leaf for the reason the upload-chunk constant has one: the check wants the arithmetic and not the upload path's RPC operations behind it. The page closure now carries none of `expo-image-picker`, `expo-document-picker`, `expo-image-manipulator` or `expo-file-system`, pinned beside the seam census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the two WebView editors their plain web fallbacks (OTA phase C, C7.6) `MobileRichMarkdownEditor` and `MobileHtmlPreview` are the session closure's other two `react-native-webview` consumers. On the web that package renders the line "React Native WebView does not support this platform" where the surface was, so nothing it was mounted for works and the closure pays for a module that cannot do its job. Ruling 8: each gets the plain state it already degrades to, and no second renderer. The editor renders the Markdown source in one field on the text-input seam, so the screen around it keeps the text, every edit through `onChange`, and Save, Discard, Copy and Refresh; the degradation is the formatting toolbar, whose fifteen commands are the rich document's. The preview renders its own Source tab; the degradation is the rendered artifact, and the toggle goes with it, because a control that can only be in one position is a control that lies. Neither is smaller than a DOM renderer, which is why neither is one here. The editor's toolbar would need a `contenteditable` implementation with its own escaping, and the preview has no nested frame to sandbox agent-produced HTML in at all — the shell's policy carries `frame-src 'none'` and `child-src 'none'`. `dismissKeyboard` blurs the field rather than calling `Keyboard.dismiss`, which is a stub on React Native Web; `onKeyboardInsetChange` is never called, because it exists to correct for a WebView's covered area and on the page `keyboard-occlusion.web.ts` is the only measurement there is. The closure census names the one consumer left, `TerminalWebView.tsx`, which is C7.5's: with both siblings removed it names all three. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a destructured clipboard alias in the media census (OTA phase C, C7.6) The census recognised `Clipboard.getImageAsync` as a property access and nothing else, so `const { getImageAsync } = Clipboard` reached the same function without ever writing one and the closure was approved. On the page that call is `navigator.clipboard`, which needs a secure context the iOS shell's custom scheme is not, so the approval was for a path that dies at the browser clipboard API. Aliases are now resolved to a fixpoint — `const pasteboard = Clipboard` makes `pasteboard` the module too, and the chain has no length limit — and a destructuring off any of them is reported at its declaration, which is the line to delete. The destructured name is read the way the import clause's is, off `propertyName` when the element renames it, so `{ getImageAsync: readImage }` is the same offence spelled differently. A binding element's `name` can be a nested pattern and a `propertyName` can be computed, so the text is taken only off a node that has one. Red-first with each shape planted in the scratch tree before the rule moved: the plain destructuring, the renamed one and the re-destructured chain were all missed. Dropping the fixpoint afterwards loses the chain; reading the local name instead of the property loses the rename. `{ getStringAsync } = Clipboard` stays unreported, because text off the pasteboard is the clipboard seam's and not this rule's. The session closure is still empty under the widened rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): release every item a clipboard pick answered (OTA phase C, C7.6) `readClipboardImage` destructured the first staged item and released only that one, while `pickImage` already guards the same shape through `readPicked`. `multiple: false` is what the page asks for and not what a shell promises, so a caller taking the first of several would hold the rest against the eight-handle cap until the five-minute TTL. Today's shell stages at most one on the clipboard arm, so this is the seam's own docstring made true rather than a leak in the field. Red-first with two staged clipboard items: releasing only the one read leaves `media-2` held, and the second is now returned without ever being read, which is what the single-image pick does. The refusal case is one path over both codes a pick can answer with: the registry's `native_media_handle_cap`, raised before a picker runs, and ruling 6c's `native_media_too_large`, raised once a picked item has been weighed. A code outside the seam's vocabulary floors to `native_verb_failed` rather than crossing verbatim, which is what makes naming the exact code load-bearing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the upload chunk from its module in the resize check (OTA phase C, C7.6) The canvas resize check restated `512 * 1024` as the budget it holds a run to. A check carrying its own copy of a product constant is one that goes on passing after the upload path's chunk has moved, which is the reason the harness reads the CSP, the protocol version and the window caps out of their own sources. `readClipboardImageUploadChunkBase64Chars` joins them, evaluating the product the way the window caps reader does. Proved live by moving the constant: at 64 MiB the run reds on the fixture no longer being over the budget, and it is back to 512 KiB here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read element access in the media census and correct two claims (OTA phase C, C7.6) Round 2, four lows. The census read `Clipboard.getImageAsync` and not `Clipboard['getImageAsync']`, which is the same call, the spelling a bundler produces, and the one a reader reaches for to get around a rule about dots. Element access with a string literal is now read the same way; a computed key is not, because its value is not in the source and guessing would report a line nobody can act on. The closure test cannot back this up — `expo-clipboard` legitimately sits in the session closure — so the scratch fixture is the whole of the evidence, and it reds with the arm removed. The fixture also could not tell the alias fixpoint from one source-order pass: every planted chain happened to be declared in the order a single walk learns it. `reverse-order-alias.ts` is declared back to front, and is valid at run time because the destructure sits inside a function the module body finishes before anything calls. Bounding the loop to one pass now reds it. The canvas resize justified reading its size back off the element by a browser clamping past its area limit. That is not what browsers do: the width attribute reflects whatever it was assigned, so the returned size is always the target. The real reason is narrower and is now what the comment and the override entry say — the dimensions and the bytes come from one element, so a caller's bookkeeping cannot describe a raster that was not encoded. The override entry also carried a stray apostrophe in `img-src 'self' data:`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what the clipboard contract shipped as, and read a backticked key (OTA phase C, C7.6) Round 3, three lows. The merge took main's `clipboard.ts` byte for byte, so its reader docstring still described the state C7.2 shipped: one verb on the web, and a page whose lack of an image verb degraded into the old path. The design that shipped is the other one — this seam owns the pasteboard on both platforms and the page's `readImage` runs `native.media.pick { source: 'clipboard' }` with the chunked read behind it. The prose now says that, and says that null still means an empty pasteboard while every other outcome rejects. The same merge left `clipboard` twice in the paste hook's dependency list, one from each side. Deduped. The census read a quoted element-access key and not a backticked one, so ``Clipboard[`getImageAsync`]`` escaped a rule that catches both other spellings. A template with no substitution is a string literal with a different quote, and reading only one of the two leaves the other as the way around. The computed-key plant could not see the literal-kind check at all: its variable was named `key`, so reading the identifier's text found nothing either way. It is now named after the method and holds a different one, which makes dropping the kind check a false positive on a call that reads text. Red-first: the backticked access planted before the rule moved is missed; ignoring template keys afterwards misses it again; accepting any key node reports the computed plant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold canPickMedia to all three verbs and seed the census from import() (OTA phase C, C7.6) Two bot findings. `canPickMedia` answered true on `native.media.pick` and `native.media.read` alone, but every image read releases what it picked. On a route without `native.media.release` the release rejects, the cleanup swallows it by design, and the staged file stays live to the five-minute TTL: eight pastes and the next pick is refused at the handle cap, with nothing on screen to say why. A route missing one verb has no working image path, so `contents()` now says so up front rather than after four of them. Red-first: a route granted pick and read but not release answered `image: true`. The census seeded its aliases from static import and export declarations only, so `const Clipboard = await import('expo-clipboard')` produced no offender — while the bundler resolves a literal dynamic import into the closure exactly as a static one. A dynamic import is now read wherever it appears: `await` and parentheses unwrapped, the assigned identifier seeded as an alias, a destructuring off one reported at its declaration, and a picker module reported at the call, since reaching one at all is the offence. A specifier that is not a literal is left alone, for the reason a computed key is. Red-first with all three forms planted and the seeding removed: the namespace alias, the destructuring and the picker import are each missed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): seed the media census from a backticked import() too (OTA phase C, C7.6 bots) CodeRabbit: `import(`expo-image-picker`)` is as static to the bundler as the quoted form, but the census read only a string literal specifier, so a backticked one joined the closure unseen. A no-substitution template literal now seeds it the same way; the planted fixture is reported at its line and was unreported before the arm. pullfrog: the clipboard seam's docstring counted the web read as two verbs where its web sibling counts one for text and three for an image. It now counts the same way in both files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d5dc7b9cf8 |
feat(mobile): budget the terminal snapshot on serialized bytes and hold live output instead of ending the stream (OTA phase C, C7.3) (#21785)
* fix(mobile): budget the mobile terminal snapshot on the bytes it serializes to (OTA phase C, C7.3, ruling 1) The desktop trims a mobile snapshot to 512 KiB of raw terminal text. A client reading it through the page bridge measures the serialized event against a 640 KiB frame cap, and an ANSI snapshot is mostly ESC bytes, each of which JSON spends six on. Measured here on a colour-dense 80-column screen: the raw budget hands back 465,766 bytes that serialize to 669,268 — 102.1% of the cap — so `deliver` answers `cancel(id, 'overflow')` and the terminal is dead before its first live byte, with no recovery that does not reproduce it. `terminal.subscribe` gains an optional `snapshotByteBudget`. A subscriber that sends one is trimmed against the JSON its payload will really cost: the escaped text, plus the metadata it cannot bound from its own side — a path, the OSC-link list, the pending escape tail. A subscriber that sends none, which is every socket client and every older page, keeps the raw byte rule exactly. No negotiation, and none is needed: the field is additive and optional, so an older desktop ignores it and trims as it always did. The page then still has a snapshot over its cap, the shell still ends the stream with `overflow` (C0.3 stands), and the terminal renders its stream-error state rather than a blank pane. The page derives the number from the cap less the event envelope rather than writing it down, so a cap that moves takes the budget with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): hold and coalesce terminal output instead of ending the stream on the window (OTA phase C, C7.3, ruling 2) The shell's backpressure window ends a stream when the page falls 4 MiB behind. That is right for a stream whose reader can survive a gap and wrong for a terminal, whose reader cannot see the hole a dropped chunk leaves — and the window does not wait for a page to go wrong. Measured by the design: the host produces 70.3 MiB/s of JSON and real xterm applies 2.2 MiB/s, so an ordinary `cat` crosses the window in 62 ms. Replayed here through the real ledger against a page draining at that rate, a 5 MB transcript ends the stream after 85 of 107 chunks plain and after 40 of 107 under `grep --color`. Keyed by method on the shell, since the page cannot pick its own window, `terminal.subscribe` now holds what it cannot send, merges consecutive output in escaped bytes under the frame cap, and delivers as the page acks. Nothing is dropped: merging concatenates, and the only exit that loses bytes is ending the stream, which the page is told about. Both transcripts now arrive whole and in order, in 104 and 81 frames, with the largest frame at 622,551 bytes against the 655,360-byte cap. It ends only on the two things that are not slowness: a page that has acked nothing for 20 s, an order of magnitude above the 1.9 s a full window takes to drain, and a backlog past 32 MiB, which at that drain is about 15 s of catching up. Both reach the page as `overflow`, because the shell is the installed app and its page comes from the desktop, so a reason the page's reader has never heard of is a frame it drops rather than an end it acts on. Which one fired, the coalesced-frame count and the peak pending bytes go to the diagnostic log, which is the device proof's only oracle for any of this. Every other stream keeps the byte window exactly, and an event over the frame cap still ends any stream, terminal or not (C0.3). The landed window cases now name a stream the window still governs, so the two rules are never read off each other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): narrow the event arm the backlog replay reads A binary event carries no `payload`, so the tests-typecheck ratchet refused the reach into it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): narrow the snapshot serializer to the buffer source it reads The changed-code casting gate refused the test's stub runtime, and it was right to: a service-wide type for a function that calls one method is what made the stub need an assertion. The parameter now says what it needs, and the fixture path is no longer one a machine-path grep reads as a leaked local checkout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): measure the snapshot budget by building the payload, not by summing fields (OTA phase C, C7.3, ruling 14) Round one summed the escaped text and four metadata fields. The payload a bridged client assembles carries nine more — `kind`, `cols`, `rows`, `requestId`, `displayMode`, `reason`, `seq` and both truncation flags — plus the `type` and `streamId` it adds, the `serialized` key and the object's own braces. So a snapshot this host accepted at exactly the budget, with `truncatedByByteBudget` false because nothing had trimmed it, published over the cap and the stream ended with `overflow` before a byte was painted. Measured here on a screen sized to land exactly on round one's budget: the published payload is 655,446 bytes against a 655,273-byte budget, 173 over, and the frame it makes is over the 640 KiB cap by the same amount. The metadata is now built by one function that `sendSnapshotFrames` and the budget both call, and the budget stringifies the payload that function produces. Nothing is summed and nothing is estimated, so a field added to the frame is paid for by the budget the moment it is sent. Where a value is not yet known — the truncation flags, and `seq` or `requestId` at a site that has not fixed them — it is measured at the widest `JSON.stringify` can write it, which is a bound rather than a guess, and forcing `seq` to a number also opens the three fields it gates so those are counted too. The budget therefore travels with the publication fields, because the payload cannot be built without them. On the page, the event envelope is now derived in one place in the protocol module and read by both the snapshot budget and the shell's own merge budget, so the two cannot drift; the page pins the number it sends and the host's cases name that pin, since the two programs cannot import from each other. The case that re-implemented the host's measure is gone: it could not have seen this, because it was the same arithmetic twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): arm a held terminal's silence clock only while something is pending (OTA phase C, C7.3) The invariant is "armed implies waiting on the page", and round one broke it in the one direction that kills: an ack re-armed the clock and the drain that followed emptied the queue without clearing it. A terminal that had delivered every byte and gone quiet — which is what a terminal does between commands — would die on `overflow` twenty seconds later. The clock is now synchronised after every change to the queue, so it is armed exactly while something is held. A rule that only ever arms is a rule that only ever ends more streams. Red-first: with round one's arming, an idle stream whose queue has drained still reports its clock armed, and firing it ends a healthy terminal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the held-stream cases the rulings name (OTA phase C, C7.3) Six cases nothing covered. Two subscriptions on one shell keep separate backlogs, so a busy terminal cannot end a quiet one. A stream the page unsubscribed mid- backlog posts nothing after, and neither does one that has already ended, however much was still held. A payload that is not output breaks a merge run and keeps its place, because a resize is state the reader applies in order. And the budget boundary is checked on the side that enforces it: a payload at exactly the number the page asks the desktop for is delivered inside the cap, and one the cap cannot hold ends the stream under C0.3. The replay no longer acks unconditionally in its catch-up loop. That was the page behaving better than a page can — it acks on reading frames — and it is what hid the silence clock left armed over an empty queue. The held-stream cases close the window on its frame count rather than on four megabytes of string work. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the event-envelope derivation its own module (OTA phase C, C7.3) `bridge-envelope.ts` is at its line cap and is the protocol's schemas; what a frame costs around its payload is a derivation over them, and two budgets read it — the snapshot the page asks the desktop for, and the output the shell merges. One module, so they cannot drift and neither file is pushed over its limit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: narrow the budget fixtures instead of asserting them The changed-code casting gate refused six `as NonNullable<...>` in the new budget cases, and it was right to: a fixture that serialized nothing is a broken case rather than a null to assert away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor: give the snapshot payload shape its own module (OTA phase C, C7.3) `terminal-snapshot-publication.ts` crossed the root config's 300-line cap, which mobile's own lint does not apply and CI does. The frame's shape and what it costs a client reading it as one payload is a description the budget and the sender both need, so it is the part that leaves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix: empty a snapshot the budget cannot fit instead of posting it over (OTA phase C, C7.3, ruling 15) Both trimming loops published the zero-row candidate whatever it measured, and zero scrollback is not a small screen: a wide colour-dense viewport still carries its 24 live rows. A capped subscriber could get one frame over its cap, end the stream on `overflow` and paint nothing — worse than a blank terminal, because a blank one repaints on the next byte of output and a stream that never opened does not reopen. Ruling 15: a budgeted subscriber gets that frame with its text emptied and `truncatedByByteBudget` true, never over and never refused. The raw rule keeps its fallback, so an older page and every socket client are served exactly what they were before. Below the metadata the frame must carry there is nothing left to give up, and that boundary is pinned rather than claimed away. The renderer loop is the same walk reached by a different caller and had no test at all; its runtime parameter is narrowed to the two methods it reads so a case can stub it without a cast. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report what a held terminal stream did instead of calling it an outlived view The backlog report had no branch in the reporter, so it fell through to the "a view outlived its host" warn and every field it exists to carry was discarded. The key made it worse: keyed by kind alone, one backlog per host was ever logged, and a shell holds one stream per open terminal. That report is the only oracle the coalescing rule has. Nothing crosses to the page saying how much was held or how many frames its bytes arrived inside, and both ways a held stream dies reach the page as `overflow`, because a reason its reader has never heard of is a frame it drops. In production the two rules were indistinguishable. They are now a line each, per stream. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: give the renderer fixture the source its serializer returns `serializeRendererTerminalBuffer` answers `renderer`, and vitest does not typecheck, so the stub's `headless` passed every run and failed the node typecheck instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix: budget the frame the publication actually sends (OTA phase C, C7.3) The budget and the publication were written out twice, five lines apart, and had drifted at every site: a budget for `{kind:'scrollback'}` approved a frame sent as `kind:'resized'` with a `reason` beside it, and the live module budgeted `pending-output-overflow` while sending `renderer-mount-ready`. It held only because the padded `requestId` and `seq` are absent from those frames and more than covered the difference. Each site now builds one object and hands it to both. `displayMode` cannot travel that way and was a third under-measure nobody had named: the subscribe flow re-reads it from the runtime after the snapshot is serialized and before the frame is sent, so no caller can tell the budget which mode the publication will carry. It joins `seq`, `requestId` and the truncation flags as a field taken at its widest. The mode list resolves the constant to `never` if the runtime gains a mode it does not carry, so a new one is weighed here rather than found on a phone. Red-first needed a second attempt: the first fixture had trimming slack, so three extra bytes fit and the probe could not see the defect it was written for. The case now budgets a fixed screen at exactly its `auto` measure, where the margin is the whole of the test. One figure for the overshoot everywhere, with its basis: 169 bytes over the 655,360-byte cap on a frame carrying an 8-character request id, 247 with a 24-character one. Three places said 169 and one said 173. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): delete two backlog guards no input can reach Both survived mutation because neither is reachable, and neither became reachable when I tried to write a case for it. `next` narrowed the merge ceiling to one frame, but its only caller, `drainTerminalBacklog`, has already narrowed it: the parameter is what one payload may occupy, not what the window holds, so the second narrowing could never change the answer. The parameter now says so and the class no longer needs the frame size at all. The bound still lives in the caller and is still covered: removing it there reds a delivery case. The merge run also compared stream ids, but a backlog belongs to one subscription and every `data` payload on it carries that subscription's single stream id, so the comparison could not fail. The run still stops at anything that is not output, which is reachable and pinned. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the invariant the deleted stream-id guard rested on The merge run compares no stream ids because it cannot need to: a backlog belongs to one subscription and every `data` payload reaching it carries that subscription's single stream id. Written down where the run is, because the thing that would break it is a change made somewhere else — multiplexing two streams onto one record would merge their output into one payload under the first id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e736a9f29b |
feat(mobile): take the session screen's inputs, links, clipboard and routers through the platform seams (OTA phase C, C7.2) (#21790)
* feat(mobile): put the session screen's nine text inputs on the web font seam (OTA phase C, C7.2) The landed text-input census, run over `app/h/[hostId]/session/[worktreeId].tsx`, reports nine sizes that do not come from `TEXT_INPUT_FONT_SIZE`. Six declare the app's body size and move in place, which is the same number natively. Three do not — a 22px key-capture field and the chat's two 15px fields — so each gets a `.web.ts` sibling of the address bar's shape, with a shared base so the two halves can differ in nothing but the size. The capture field is the one the move shrinks rather than raises: 22 already clears the focus-zoom floor, and the census reads the seam as a binding rather than as a number, so there is no expression that keeps 22 and still says where the size came from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the theme import the composer's style split left behind `oxlint` over the whole tree, which CI runs, reads it as an error. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): open the session screen's three external URLs through the platform seam (OTA phase C, C7.2) The landed external-link census, run over the session route's closure, reports three modules reaching react-native's `Linking`: a terminal link tap whose open mode is the phone's browser, and the two WebView-backed readers, each of which sends a tapped link to the system browser rather than navigating the artifact away. Inside the shell `Linking.openURL` calls `window.open`, which both shells refuse and which resolves either way, so all three reported success into a tap that did nothing. The seam also stops swallowing the failure: each site caught and discarded, and `openExternalLink` names it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): take the session screen's seven clipboard sites through the platform seam (OTA phase C, C7.2) `expo-clipboard` resolves to `navigator.clipboard` on the web, which needs a secure context — iOS serves the page from a custom scheme and Android from `https`, so that path works on one platform and silently not on the other. The landed census now reports the module out of the route's closure entirely. The seam grows its reader half, on the landed `native.clipboard.read` verb: text, a PNG, and a presence probe. Two degradations are recorded rather than implied. No shell serves an image, so the page answers null and the terminal's paste takes the branch an empty clipboard already took; and the shell serves no presence verb, so `contents` answers what this side knows rather than reading to find out, which would raise iOS's paste-consent prompt on every foreground. The copy-path sheet gains the failure toast its two neighbours already had: it showed "Path copied" before the write, and the seam rejects rather than returning false. The route parity pin moves with it: five clipboard hooks join the expanded route and one runtime string joins the sheet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): take the session domain's three routers through the handoff seam (OTA phase C, C7.2) Inside the page a screen is one document standing in for one screen, and `useRouteHandoff` is the only thing that knows which targets the page keeps and which it hands back to the app. The three holders here are the workspace-missing bounce, the file-tap preview push, and the pane-tap param consume. The domain's census is narrower than the two landed ones because it has to be: eight of its hooks take `useFocusEffect` and two take `useLocalSearchParams`, neither of which can navigate, so the rule is a closed list of names rather than a ban on any value import — which also catches expo-router's module-singleton `router`, a spelling a `useRouter` rule would have read as clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the two style siblings the session screen's inputs added (OTA phase C, C7.2) The overrides census fails on an unlisted `.web.*`. One raises the chat's two 15px fields past the focus-zoom floor; the other lowers a 22px capture field onto the seam, and its entry says why a reduction is the right answer there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the text-input census read a literal already clear of the floor (OTA phase C, C7.2, ruling 12) The floor is the rule and the seam is the mechanism. A binding rule alone made the custom-key capture field an offender at 22, where nothing can zoom, and the only way to satisfy it was to lower a one-character field to 16 — the tail wagging the dog. The seam's web half now exports the floor it already computed `Math.max` against, and the census reads that number out of that file rather than carrying a second copy of 16. The rule becomes "the seam's binding, or a literal at or above the floor", with no per-site exemption: a literal under the floor is still reported, which is the case the seam exists for. A tree whose seam declares no floor is refused rather than judged against a number the census invented. So the capture field goes back to 22 on both platforms and its split, its override entry and its parity test go with it. The chat's two fields stay split, because 15 is under the floor however it is spelled. Red-first: with the rule removed, a planted literal 16 and a literal 22 are both reported and the refusal case does not throw; a literal 15 is reported either way. All three route closures that run this census — session, source-control, review — report 0 offenders and 0 unresolved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): answer clipboard reads on the read grant and catch refused writes `contents()` reported no text on a route that granted `native.clipboard.read` without the write, because it read `verbs.granted`, which is write AND read. The verbs hook now exposes the two grants separately and the web seam answers on the read one; `granted` keeps its meaning for the callers that need both. The Markdown copy action was the one write of eight in the session domain with nowhere for a rejection to go: the seam rejects when the pasteboard refused the text, the callback had no failure branch, and its caller drops the promise, so a refused write raised an unhandled rejection and still left "Copied" on screen. It now takes the error haptic and the "Couldn't copy" toast the other copy paths show. A census over `src/session` fails if any `writeText` call site lacks a failure branch, so the ninth site cannot arrive without one. The route parity pin moves with it: one callback body, one runtime string. Its refresh note claimed six clipboard hook sites for a delta of five; the walk from `SessionScreen` reaches five, and the terminal's paste is not among them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): probe both clipboard kinds at once and judge sizes against the floor Moving the two clipboard probes into an object literal serialised them: the migrated `contents()` awaited `hasStringAsync` before `hasImageAsync` was called, where both callers had used `Promise.all`. That path runs on mount, on every AppState foreground and on every select-mode toggle. Restored, with an ordering probe that deadlocks unless both probes start before either answers. The floor case could not fail for the reason it named: its fixture declared 16, so a census carrying its own copy of 16 passed it. It now plants a seam declaring 20 and a literal 18, the size that is clean under one floor and an offence under the other. Two stale wordings from the reverted split: one closure case still said "both split style modules" over a one-element list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): buzz the quick-command row when a copy is refused The last of the seven migrated writes without the error haptic. The row already said "Couldn't copy" on its own control, in red, for the 1500 ms the toast the other six show would have lasted, so it never claimed a refused write had landed; what it had no way to say was anything the thumb still on the button could feel. Its first test, on the harness its list already uses: the seam rejects when the pasteboard refuses, and the two cases are the difference between the row that shows a green check over nothing copied and the row that does not. The list's own test gains the haptics mock the row's new import needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the clipboard census require the await its rule depends on `hasFailureBranch` accepted any enclosing `try` with a `catch`, so the one shape the census exists to stop passed it: `void clipboard.writeText(...)` inside a try/catch is an unhandled rejection with a handler three lines above it that can never run, because the block returns before the promise settles. It now requires the call to be awaited inside the try's own block, or to carry a `.catch` along its own chain. The boundary walk stopped only at function and method declarations, so a `catch` outside an arrow answered for the call left running inside it; every function-like node ends the search now. Five cases over snippets read through the same reader, because a `void` write would have to be committed to be tested against the real tree. Control on a real site: making the Markdown write un-awaited inside its own try reports it. Two provenance fixes. The runtime-string delta across C7.2 is two literals, not one: "Couldn't copy path" took the count from 532 to 533 and "Couldn't copy" took it to 534. And main's C7.4 made `BRIDGE_CLIPBOARD_MIMES` `['text']`, so an image mime is a value the schema does not admit rather than a refusal the verb spells out, with `native.media.pick { source: 'clipboard' }` waiting on C7.6; the web seam and its test said otherwise. Behaviour unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let an unmounted quick-command row emit nothing on a refusal The haptic I added ran before the mounted guard, so a copy pressed on a row that then scrolled out of the list, or a sheet closed over it, still buzzed when the rejection arrived. A buzz with no row to explain it is feedback for nothing, and the guard was already there for the feedback state one line below. Red-first: press, unmount, then reject. The success path already guarded first. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the clipboard census require a catch with something in it Any `.catch` property access counted as a failure branch, so two shapes that handle nothing passed: `clipboard.writeText(text).catch` reads the handler's name and registers nothing, and `.catch()` swallows the rejection while the caller goes on to say the write landed. The rule now requires `.catch` to be the callee of a call carrying at least one argument. Red-first with both shapes in the snippet reader, the accepting cases unchanged. Control on the real tree: emptying the notes sheet's handler reports `MobileSessionSheets.tsx:174`, and restoring it greens. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e5181113ca |
feat(mobile): add the media verbs the page's pickers and paste will use (OTA phase C, C7.4) (#21779)
* feat(mobile): add the media verb rows to the shell's native table (OTA phase C, C7.4)
`native.media.pick`, `native.media.readChunk` and `native.media.release` join
`BRIDGE_NATIVE_VERB_NAMES`, so each becomes a grant name `init.grants.native` can
carry and each gets a strict zod contract in `bridge-media-verbs.ts`. A chunk read
is held to the upload path's own budget, imported rather than restated: the leaf
module `mobile-clipboard-image-upload-chunk.ts` now declares
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` so the bridge can read it
without pulling the upload path's RPC operations into every page closure.
Inert: no page calls the verbs and no route declares them, so an older page never
names them and an older shell refuses them as `unknown-verb`.
`native.clipboard.read`'s `{ mime: 'image' }` refusal is retired rather than left
pointing at nothing. The broad shape existed so a later build could serve an image
without a contract change; that build is `pick { source: 'clipboard' }`, which
stages the image behind a handle instead of inlining 24 MiB of base64 through an
8 MiB reply. The mime enum is text only, the handler's out-of-scope arm is gone,
and the refusal's test flips to the new answer: `invalid-params`, before dispatch.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the shell the staged-media handle registry (OTA phase C, C7.4)
A handle names a file this shell copied into its own cache, and such a file has no
owner otherwise: the page that asked for it is a document that can navigate, fault
or be swiped away without telling anyone. So the lifetime is bounded four ways and
all four are here — the page's own `release`, `MEDIA_HANDLE_TTL_MS`, and
`releaseAll`, which both the session's end and the page's unmount reach through
`useMediaHandleRegistry`. The TTL is measured from the last touch, not the mint, so
a page reading a large file one chunk at a time cannot have it swept out from under
it; its five minutes is sized for the page that picked and then stopped, not for
the read, which is 48 round trips on the largest item a pick may stage.
`BRIDGE_MEDIA_MAX_LIVE_HANDLES` caps what one session holds. A pick that would pass
it is refused whole and discards what it staged: half a multi-select is an answer a
page cannot tell from a user who picked fewer.
Three refusals join the seam's vocabulary. `native_media_handle_unknown` covers
never-minted, released and swept alike — which of the three it was is a fact about
another page's pick. `native_media_range` refuses a read at or past the end of an
item that had bytes, because the previous chunk already said `eof` and an empty
answer would let a page loop instead of failing where the bug is.
`native_media_handle_cap` is the cap above.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): serve the media verbs on the device, behind staged handles (OTA phase C, C7.4)
`pick` runs the OS permission prompt inside the shell — `expo-image-picker` for the
library, `expo-document-picker` for Files, the pasteboard for `clipboard` — stages
each item as a file this shell owns, and answers handles. `readChunk` reads the
byte range the registry hands it and base64-encodes it through the upload path's
own accumulator; `release` deletes the file.
The device calls live in `native-media-device.ts` and nothing else. Importing
`expo-image-picker` imports React Native, so a module naming it cannot be driven in
a unit test at all, and the arms worth pinning are exactly the ones a simulator
makes expensive: a denied permission, a cancel, an item over the staging ceiling.
`native-media.ts` takes those seven calls as dependencies and is tested whole.
Two refusals join the vocabulary, each because a page acts differently on it.
`native_media_permission_denied` is a permission the user can still grant in
Settings, not a library the shell could not read. `native_media_too_large` is an
item over `MEDIA_STAGED_MAX_BYTES`, weighed from the staged file rather than from
what the picker declared, since a picker's own size is optional on both platforms.
A pick refused that way discards every file it staged.
Each chunk is base64 on its own, so a page concatenates decoded bytes and never
strings: only the last chunk of a read ends on a partial group.
Measured: the largest reply this verb can produce is a full
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` chunk, 524,427 bytes on the
wire with its envelope — one frame, 80.0% of the 640 KiB frame cap and 6.3% of the
8 MiB reply ceiling. Pinned in `native-media.test.ts`, so a cap or an envelope
field that moves shows up as a diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): only mint a media handle over a file this shell owns (OTA phase C, C7.4)
The Android arm of the same handler. Both pickers are configured to hand back a
copy in this app's own cache — `expo-image-picker` always copies, and
`expo-document-picker` does under `copyToCacheDirectory` — and the whole handle
contract rests on it: `release` is a delete, and so is the TTL sweep. A provider
that answered `content://media/...` instead would mint a handle over a file this
shell can neither size nor delete, and every sweep would be a silent no-op that
leaves the cache growing. `ownsStagedMediaUri` refuses that where the assumption
is made rather than letting it surface as a cache that never empties.
`native-media-device.test.ts` pins the options that make the assumption true: the
document picker's `copyToCacheDirectory`, the library picker's `base64: false` and
its selection limit, the pasteboard's png, and the cache file `stageBase64` writes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): move a picked image across the real pair, chunk by chunk (OTA phase C, C7.4)
Through `createFakeBridgePortPair` rather than the host alone, because a chunk is
the one reply this seam produces that is within a rounding error of the frame cap:
a case that called the server directly would never serialize one, and serializing
is where a reply that fits and a reply that splits part ways.
A granted page picks an 18 MiB item, reads all 48 chunks in order to `eof` and
releases it; the decoded length is the file's own and both ends are the fixture's,
which a reader that dropped or reordered a chunk would not have. Nothing reaches
the desktop client. A page granted only `navigate` and `storage` is refused
`native_verb_ungranted` with no picker run — the refusal is the host's, because
the page side of these verbs is C7.6's. A released handle, a swept handle, a read
past the end and a pick over the live-handle cap each come back under their own
code.
The pair's default verb handler answered a clipboard shape for every verb, so a
media call through an unconfigured pair came back as `native_verb_result` — a
shell bug's code for a harness that was never told about the verb. It now answers
one shape per row of the table.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the four type assertions the changed-code gate found (OTA phase C, C7.4)
`new Array<T>()` for the two hoisted mock ledgers, and the probe holds its handler
in a record it null-checks rather than asserting one that a render might not have
produced.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): rename the chunk verb to native.media.read, which a manifest may name (OTA phase C, C7.4)
`native.media.readChunk` is not a legal grant name. `GRANT_NAME_PATTERN` holds every
`native.` segment to `[a-z][a-z0-9]*`, so a route declaring it fails
`MobileWebBundleRouteSchema` — and `bundled-mobile-web-bundle.ts` parses the manifest
whole, so the camel-cased segment is not a route that degrades to its native screen.
It is a bundle the phone rejects entire. Params are unchanged.
The test that would have caught it now reads the schema itself rather than a copy of
its pattern: every entry of `MOBILE_WEB_SHELL_GRANTS`, and every name in
`BRIDGE_NATIVE_VERB_NAMES`, parsed as the grants of a manifest route. Both lists,
because the spread is what makes them agree today and a build that stopped spreading
would leave this the only thing that noticed. Red on the old name with the exact
message: `native.media.readChunk: expected false to be true`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): exercise a short read, which nothing separated from the chunk cap (OTA phase C, C7.4)
Every other read in the suite asks for a whole chunk of a file shorter than one, so
a handler that ignored `length` and read to the end passed all of them: the fake
file clamps at its own size. Replacing the range with the cap left 74/74 green.
A 16-byte read at offset 400 of a 1000-byte item now pins the length, the offset and
`eof: false`. Under that same mutation it reds with `expected 600 to be 16`, and it
is the only case that does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): kill the four device arms that survived mutation (OTA phase C, C7.4)
Each was green with the arm removed; each now reds with it removed and nothing else
does.
The data-url strip: `getImageAsync` answers `data:image/png;base64,...`, which is
what an `<Image>` source wants and not what a file wants. Staged unstripped, every
byte is shifted by the prefix and the page decodes a corrupt image with no error
anywhere. The old fixture had no prefix at all.
The cancel flag: both pickers answer `assets: null` beside `canceled: true` today,
so a handler keyed on the list alone passed every fixture here. The new case sends
the flag with a populated list, which is what a picker version that changed its mind
would send.
The unknown mime: an empty string is not a mime the result schema takes, so the
alternative to the floor is `native_verb_result` — a shell bug's code for a document
picker doing what it may do.
The handle close: a ledger per opened handle, asserted on the way out and on the way
through a reader that throws. A file handle a shell leaks is invisible on a fake and
a file descriptor on a phone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the stated constants as literals (OTA phase C, C7.4)
The TTL, the live-handle cap, the handle and mime lengths, and the 48 chunks an
18 MiB item reads in. Literals, not the constants restated: every one is a number a
body claims and a reviewer checked, and read through its own name the assertion
would hold whatever it became — which is what the 524,427-byte reply pin already
does for the frame it measures.
Each reds when its number moves: 8 to 12, 64 to 32, 128 to 256, five minutes to ten.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the fixture by its size, assert the clipboard refusal, reach the clamp (OTA phase C, C7.4)
Three minors from round 1.
The case that said "18 MiB" staged a million bytes. Renamed to what it stages, with
a line saying the largest item a pick may hold runs in the port-pair suite, where
the frames are serialized and the size is the one that matters.
The clipboard image case had been weakened to a bare `.rejects.toThrow()`, which
would pass for a handler that reached the pasteboard and failed there — the one
outcome it exists to rule out. It now asserts a `ZodError` naming the `mime` path,
and reds when the parse is taken out.
The registry's chunk clamp was unreachable behind the params schema. Kept and
reached rather than deleted: the two bounds are different promises, one saying what
a page may ask for and one saying what the registry will hand any reader, and
`read` is a public method its own suite already calls directly. A case asks for
three chunks at once and gets one; it reds when the clamp goes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): copy a provider uri into the cache instead of refusing the pick (OTA phase C, C7.4)
`MediaHandler.readExtras` in expo-image-picker 55.0.24 has a reachable arm: when
`toMediaType` cannot resolve a MIME it answers
`ImagePickerAsset(type = null, uri = uri.toString())`, the provider's own
`content://` uri, uncopied. The OS completed that pick, so refusing it as
`native_verb_failed` loses a photo the user chose — and the comment above the guard
claimed both pickers "always" answer `file:`, which the same file disproves.
The pick now copies what it does not own into the shell's cache and mints the handle
over the copy, which is what the body already claimed. The guard stays for anything
the copy could not adopt, and that arm still fails closed: a sweep that can never
delete anything must not look like one that did. The failure path discards what this
shell owns rather than the uris the picker answered, since a provider's uri was
never ours to unlink.
Copied through `bytes()` rather than `copy()`: `FileSystemPath.copy` goes to
`javaFile.copyRecursively`, a `java.io.File` with nothing to open for a provider uri,
while the read path goes through the unified file and does. A source that cannot be
read takes the empty destination with it, because the caller never learns that name.
Red-first: a fixture answering `content://media/external/images/media/42` asserting a
handle over a `file:` copy, its 300 bytes read back whole, and the copy being what
`release` deletes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): bound a multi-select to the room the registry has left (OTA phase C, C7.4)
`selectionLimit: 0` means unlimited to the OS picker, and `mint` then refuses the
whole pick past `BRIDGE_MEDIA_MAX_LIVE_HANDLES`. A user who chose nine photos waited
through the OS copying every one of them into the cache to be told none were taken.
`pick` now reads `remainingCapacity()` before it launches anything: an empty room is
`native_media_handle_cap` up front, for every source, with no picker run and no byte
copied. What room is left becomes the picker's `selectionLimit`, so the selection
cannot exceed what the registry will accept and the refusal after the fact is only
reachable by a page that never released what it holds.
`getDocumentAsync` takes no selection limit, so for Files the room is the up-front
refusal only; `mint` remains the bound that cannot be skipped.
Each pin reds on its own mutation: dropping the up-front refusal, putting
`selectionLimit` back to 0, and fixing the limit at the cap instead of the room.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): ask for the library permission on iOS only (OTA phase C, C7.4)
Read from expo-image-picker 55.0.24, not from the docs. Neither platform's
`launchImageLibraryAsync` gates on a permission: `launchCameraAsync` calls
`ensureCameraPermissionsAreGranted` on Android and `hasGrantedPermission` on iOS, and
the library arm goes straight to its contract on both. On Android
`getMediaLibraryPermissions` answers an empty array from API 33, so the request
prompts nothing and always resolves granted; below 33 it asks for
`READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE`, which the system picker never
reads, and a denial there reached `native_media_permission_denied` for a pick the OS
would have completed. Android no longer asks.
iOS still does, because the prompt inside `pick` is what ruling 6 asked the shell to
own and the photo-library dialog is a real thing a user sees there.
One finding for the lead rather than a silent change: with `allowsEditing: false`
the iOS path is `launchMultiSelectPicker`, a `PHPickerViewController`, which also
opens without authorization. So the iOS request is the ruling's and not the SDK's,
and a denial refuses a pick that would have worked. Dropping it is a ruling-6
amendment, not mine to make.
Both arms pinned and both red under their mutations: asking everywhere fails three
cases, asking nowhere fails two.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mock react-native in the device-verb hook suite (OTA phase C, C7.4)
The hook reaches `native-media-device.ts`, which now imports `Platform`, and reaching
the real module imports React Native — which this suite has no runtime for, so it
failed to load and ran zero tests while the run still reported every other file
passing. The device half's own platform arms are `native-media-device.test.ts`; here
the OS only has to be one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): ask for no library permission on either platform (OTA phase C, C7.4)
Ruling 6b. `launchImageLibraryAsync` gates on nothing in expo-image-picker 55.0.24:
Android goes straight to its contract and iOS to `launchImagePicker(.photoLibrary)`,
and only the camera arm checks a permission. The prompt "inside the shell" that
ruling 6 asked for is the OS picker's own, so `pick { source: 'library' }` adds
none. `requestLibraryPermission` is gone from the deps, from the device calls and
from `pickFrom`, and with it the last way a denial could refuse a pick the OS would
have completed — Android below API 33 for storage permissions the picker never
reads, iOS for a `PHPickerViewController` that opens without authorization.
`native_media_permission_denied` stays in the refusal vocabulary, documented for the
first source that needs one rather than for a producer this build has.
Red-first, and driven through the whole verb rather than through `launchLibrary`
alone, since the request was `pick`'s and a case that only called the picker would
have passed either way: with the request restored, the pick on iOS calls the
permission API once and the deps still carry the member, and both cases red.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): sweep before release, and name one basis per ratio (OTA phase C, C7.4)
Two lows from round 2.
`release` was the only lifetime path that did not sweep first, so an expired handle
was still in the map when it looked and the page was told `released: true` for a file
the next sweep would have taken anyway — the opposite of what
`mediaReleaseParamsSchema`'s docstring promises it. It sweeps now, like every other
path that reads the map. Red-first with the reviewer's own probe: expired, then
released, answered true. The exact boundary is pinned while we are here, because the
sweep's comparison is inclusive and nothing said so: at exactly `MEDIA_HANDLE_TTL_MS`
the handle is gone, one millisecond earlier it is not.
The raw ceiling was called "three times the reply ceiling" in two places.
`CLIPBOARD_IMAGE_MAX_SOURCE_BYTES` is 18,874,368 against 8,388,608, which is 2.25;
three is the ratio after base64 expands it. Both sentences now name which basis they
are on, and say that the two differ.
Each pin reds on its own mutation: dropping the sweep from `release`, and loosening
the sweep's comparison to exclusive.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): never sweep a uri this shell does not own, and weigh before copying (OTA phase C, C7.4)
Round 3's five items.
(1) `owned.push(uri)` ran before the ownership guard, so a copy that could not be
adopted sent the provider's own `content://` uri to the failure path's discard — the
one thing the array's docstring says it never holds. The push now happens below the
throw, inside a named `adopt` that either hands back a uri this shell owns or throws
having claimed nothing. Red-first: the refusal case discarded exactly
`["content://media/external/images/media/42"]`.
(2) The copy's docstring claimed the in-memory read was bounded by a ceiling checked
"right after", which ran after the whole `bytesSync()`. The source is now weighed
before the copy, which is real: `FileSystemFile.size` routes a `content:` uri to
`SAFDocumentFile.length()`. A provider reporting nothing answers 0 there, so the
docstring also says what is true for that case — the read is bounded only by
`mediaTypes: ['images']`, and the copy is weighed after the fact instead. The copy's
own size stays authoritative for the handle.
(3) Both `react-native` mocks went inert when `324c4093be` dropped `Platform`. Gone,
along with the two-platform loop that had become one code path run twice; the 6b
guard now reads "never calls `requestMediaLibraryPermissionsAsync`", which is what it
was checking.
(4) The hook case no longer claims a permission prompt.
(5) The 150-character comment is wrapped, along with the three other comments over
100 that I had authored in this file and the two platform modules.
Each behavioural pin reds on its own mutation: pushing to `owned` above the throw,
and dropping the pre-copy ceiling check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse what cannot be weighed, and count a Files pick before staging it (OTA phase C, C7.4)
(a) The pre-copy weigh is now a mechanism rather than a docstring, and the branch it
takes is the one the SDK allows. Verified in expo-file-system 55.0.26: a `content:`
uri reads its size through `SAFDocumentFile.length()`, so the common case is weighed
before `bytesSync()` materializes anything. There is no bounded read to fall back on
for the rest — `File.open()`, `readableStream()` and `writableStream()` all reach
`FileSystemPath.javaFile`, which throws `This method cannot be used with content
URIs` outright, leaving `bytesSync()` as the only read a provider uri has and it is
all or nothing. So an item whose provider reports no size, which answers 0 and is
indistinguishable from an empty file, is refused rather than read at an unknown
size. An empty pick was nothing to stage either way.
(b) `getDocumentAsync` takes no selection limit, so the room `pick` hands it was
advisory and a user could return more than the registry holds. Counted before
staging: previously all nine assets were copied into the cache and `mint` refused the
lot, which the red run showed as nine discards. The refusal is
`native_media_handle_cap` and it sweeps nothing, because nothing of this shell's
existed yet.
Three pins, each red on its own mutation: dropping the unweighable refusal, making
the room check a no-op, and taking it off the Files arm.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the last two traces of the library permission prompt from the media tests
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
4e9d5b577e |
test(mobile): pin the decoded screencast frame with the base64 it now carries (OTA phase C, C6) (#21769)
C6.1 (#21758) pinned the frame `decodeBridgeScreencastFrame` hands back with an exact `toEqual`; C6.2 (#21754) made that decoder carry the wire's `b64` on the frame so the page's data URI can reuse it. Each PR was green against the main it branched from, and their squashes together red two of C6.1's cases on main. The pins stay exact and gain the field, with the encoded string spelled out rather than wildcarded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
6f0fb3fe39 |
feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2) (#21754)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2) The pane's frame path is written against React Native's native-prop writer, which does not exist on React Native Web: a ref there is the DOM node, so both writes throw and the pane never shows a frame. Three `.web.ts` siblings, each for a measured gap. - The image and layer writes move out of `mobile-browser-frame-state.ts` into `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image` on the element RN Web sizes and flips the double buffer with one opacity write per layer. The pane still never re-renders while it streams. - A `background-image` write fires no load event, so the offscreen layer would never become visible. The sibling arms the flip from an image decode instead, and the flip itself is shared with the native `onLoad` path rather than written twice. - The data URI keeps the base64 the bridge already carried instead of encoding the bytes back into the same string. Measured in this tree against the `buffer` shim the page bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against under a microsecond for the carried string. Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and renders its existing stream-error state otherwise, so a page never waits on frames a shell without the encoder cannot send. The grant name is a placeholder until C6.1 reports it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2) C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the placeholder this PR landed with while C6.1 was still choosing. The placeholder was also unusable, which the test added here would have caught: `GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed verb and nothing else, so a route declaring `browser.screencast.binary` would have been refused by the bundle before any shell saw it, and the pane would have taken its stream-error branch for a reason no screen could report. The name is now checked against `MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted spelling as the failing case beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2) Round 1 folds on #21754. The undecodable arm freed the pending slot without checking whose frame had failed, while the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame 3, then frame 3 decodes and the flip is refused because the slot no longer names its layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has gone still sends no further frame to recover with. Web only; native never calls this. Both arms now answer for the frame they were armed with. The displayable arm's own guard had no test: deleting it left `src/browser/` and the full suite green, because the case that exercised it settled both decodes and asserted an end state both orders produce. The harness now settles one decode at a time, keyed on the source it was given, and the ordered case reds without the guard. Also: the paint sibling's opacity test claimed "no re-render" while asserting two style strings, so it is named for what it checks and the claim is counted where React is — across ten streamed frames the three state setters are called once each, on the mount frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so two pre-existing reasons keep their literal em dash instead of a re-serialized escape. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2) Round 2 folds on #21754. Every test for the web paint sibling handed it a `div > div` of its own making, so the assumption it rests on — that the host's first element child is the one carrying the frame — was only ever checked against a shape written to match it. React Native Web also renders an accessibility `<img>` in there, and a release that reorders those children would keep all of them green while the pane painted nothing. One test now renders the real component, asks it which child it painted, and checks the write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the hand-built cases passing, which is the gap. A second case records what the `<img>` does: the streaming path writes styles and never props, so it keeps the source it mounted with for the life of the pane, and that is what a screen reader and the image context menu see. react-native-web ships no type declarations, so the component comes through `createRequire`, whose return is `any` at its own signature; the one prop it renders with is declared rather than asserted, and the file stays inside the tests-typecheck ratchet. The module docstring also claimed more than the code does. The frame path adds no render, but a render from any of the pane's other state — address focus, a dialog, the view mode, zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`, so both land on the newest frame whether or not it has decoded. Native clobbers the same way through `setNativeProps`. Said plainly, along with what restores the buffering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2) pullfrog is right, and the test carried the same wrong claim. The note said the hidden `<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after saying a render from the pane's other state passes `renderedFrameSource` as `source` — and React Native Web derives that image's `src` from the same prop it paints the background from, so the first such render moves it. Measured here rather than reasoned about: rendering the real component, writing a frame imperatively, then re-rendering with a new source moves the `src` and leaves the background where the imperative write put it. The two halves are now two cases, named for what each one shows, and the note says the streaming writes never touch it while a render does — so it holds the frame the pane last rendered with, not the one on screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
f5d2d6e757 |
feat(mobile): carry browser screencast frames over the bridge as base64 (OTA phase C, C6.1) (#21758)
* feat(mobile): carry screencast frames over the bridge as base64 (OTA phase C, C6.1) `bridge-screencast-binary.ts` landed in C0 as the page's half of the binary lane and named C6 as the owner of the encoder that satisfies it. This is that encoder, plus the host honouring `wantsBinary`: a subscribe that asked for binary gets an `onBinaryFrame` on the native stream, and each frame crosses as the envelope's `event.binary` on the same `seq` ledger as the stream's JSON events, because the page acks by that count. The base64 encoder is grouped rather than per byte or per `fromCharCode` window. Its docstring carries the measurement, including the part that contradicts the design note this came from: on V8 the per-byte form is the fastest of the three, not the quadratic one, and the chunked form it was meant to beat is the slowest. The grouped one is here because its cost does not depend on how an engine ropes `+=`, and Hermes is what the shell runs. No new opcode, no `v` bump, no negotiation added: `wantsBinary` is already in the contract and is the negotiation. Over-cap behaviour is unchanged in this commit — a binary event over the frame cap still ends the stream, which is what C6.2 changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): drop an over-cap screencast frame instead of ending the stream (OTA phase C, C6.1) Measured at the pane's own request parameters, a screencast frame exceeds the 640 KiB envelope on a phone layout whenever the page will not compress: JPEG's worst case is 0.545 bytes per pixel at quality 72, so mobile view mode at 780x1424 is 811,289 bytes, 124% of the cap. Ending the stream there blacks out a browser tab for the life of the pane over one frame. So the two kinds of event part at the cap. A JSON event that will not fit still ends the stream with `overflow`, because its reader cannot see the hole it would leave; a screencast frame is dropped and the stream lives, because the next frame is one throttle interval away and the pane is still showing the last one. Both are asserted side by side so neither turns into the other. A drop leaves no other trace: the diagnostic beside it prints once per host, so a stream shedding a frame a second and one that shed a single frame read the same. The host therefore counts them per stream for the diagnostic and keeps a session total, and the shell's dev facts carry that total — the surface that already shows build state, with the line moved into its own module so what it says is pinned rather than inferred from a template. The 12-character build prefix it has always shown is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name the binary screencast lane as a grant (OTA phase C, C6.1) Ruling 5's negotiation, and the check it asked for first: no reader of a grant is a closed enum, so there is no blocker and nothing an older page has to tolerate. `BridgeGrantsSchema.native` and the shell's manifest reader are both open string arrays, and the shell reader's own docstring already states the degradation — a grant name a build does not know leaves that one route native rather than refusing the bundle. What does constrain the name is the host contract's `GRANT_NAME_PATTERN`: a grant is one camelCase token or a `native.<domain>.<action>` verb with at least two dot segments. So `browser.screencast` and `native.screencast` are both refused, and the lane is `screencastBinary`. `screencast` alone would be wrong: the page can already subscribe to `browser.screencast` and receive its JSON events, and only the binary frames need the encoder. Added to the shell's implemented set, which is the same list `init.grants.native ` offers, so a route declaring it is served by a shell that has the encoder and left native by one that does not. No route declares it here; C7's session route does. The contract-side case is a characterisation pin, not a red-first one: the pattern already admitted this name, and the test records that the two tempting spellings are the ones it refuses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): check the dropped-frame total through the bridge hook (OTA phase C, C6.1) The hook gained a required `onBinaryFramesDropped` two commits ago and this test kept calling it without one, so the tests-typecheck ratchet went red on that commit — caught here rather than in CI because an exit code was read off a pipeline's last stage instead of the script. Fixed by wiring the callback into the probe rather than by a cast, and with the case that makes the wiring evidence instead of types: a dropped frame raises the total the screen receives, and the stream stays subscribed while it does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the dropped-frame counter with the ledger it belongs to (OTA phase C, C6.1) Declared between a getter and a method, which is not where this class keeps state: the subscription map is at the top and the counter is the same kind of thing. Move only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve the binary screencast lane only to a route granted it (OTA phase C, C6.1) Reported as a gap after C6.1's third commit and ruled on: the host honoured `wantsBinary` from any page, so a route that never declared `screencastBinary` could still make the shell encode base64 on its behalf. That is the hole per-route grants exist to close — the same class as a route granted only `navigate` and `storage` reaching the clipboard. The rule now reads the session's resolved list, which is what its route declared narrowed to what this shell implements, and is the same set `init.grants.native` is built from. So the host offers the lane in `init` exactly when it will serve it. Ungranted is not a refusal. The subscription proceeds and its JSON events cross as before, which is the silence every other grant gives at the call site; a page that reads its own grants never reaches that state. Both branches are pinned beside each other, and `grantsForRoute` is pinned dropping a grant this shell does not implement — granted-but-unimplemented and never-granted arrive at the host as the same absence, so its rule reads one case. The grant name moves into the module that holds the rule reading it, so the two cannot drift. `bridge-host.ts` is at 298 of its 300-line cap after this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the page's stream-frame rules out of the host (OTA phase C, C6.1) `bridge-host.ts` reached 298 of its 300-line cap, so the next main merge that touched it would have crossed under CI pressure on someone else's PR. Split deliberately instead, at the boundary the growth came from. `bridge-host.ts` is the host's lifecycle and its dispatch. Opening a stream is the only frame kind whose handling is more than one line of delegation — four refusals and, since C6.1, the binary-lane decision — so it moves whole, and `cancel` and `ack` move with it so all three stream frames are decided in one place. The host's `cancel` arm still chooses between a stream and a request where it always did: a page's `cancel` names one or the other, and splitting that choice would leave half an arm in each module. Counted without blank lines or comments, as the rule counts them: bridge-host.ts 298 -> 270, and the new module is 59. A pure move. No test changed and none was added, which is what makes the existing suites the proof: 45 files and 745 tests green on the same assertions as before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): report a page that asked for screencast frames it was not granted (OTA phase C, C6.1) An ungranted `wantsBinary` is not a refusal on the wire, so nothing crosses back: the subscription proceeds and its JSON events cross as they always have. That left a page which did ask getting JSON for the life of the document with no side able to say why. `notify-refused` has covered the equivalent notify case since C0; this is the same shape for the one frame kind that lacked it. The rule now answers a verdict rather than a boolean, because `not-asked` and `ungranted` are the same answer for different reasons and only one is worth reporting. So the decision and the report read one rule, and a page that never asked stays silent — pinned, along with a granted route staying silent, so the line cannot start firing on either. The wire is unchanged and pinned unchanged: the case beside this one still asserts one JSON event delivered and zero error frames. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): reset the dropped-frame total with the host that counts it (OTA phase C, C6.1) Round 1 on #21758, three findings. The real one: the count is per host and the screen's copy was not. A rebuilt host starts its own total at zero, so the screen kept the retired host's number until the new one dropped a frame and then read *lower* — a falling count looks like frames coming back, which is worse than starting over. The hook now announces a fresh count as it builds a host. That also reports zero on the first build, where the screen is already at zero and React bails out of the render; the two hook cases pin that leading zero rather than leave it to be rediscovered. Two docstrings that described nothing: `BUILD_ID_PREFIX_LENGTH`'s stayed behind when the constant moved to the dev-facts module and had drifted above `failureMessage`, and `page-route-policy.test.ts` kept the docstring of the test it replaced above the one that replaced it. Both deleted; the first's text lives on the new module. Red-first for the reset, checked against its final expectations rather than its first: with the one line reverted both hook cases fail on the missing zero, and both pass with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the dropped-frame total out of a production build's render path (OTA phase C, C6.1) CodeRabbit's Major on #21758. The total went into React state on every dropped frame in every build, and outside a development build the line that reads it renders null — so an over-cap page re-rendered the whole shell screen up to ten times a second for a fact nobody can see. Measured, not argued: five drops, five extra renders. Fixed at the seam rather than with a ternary at the call site. The dev-facts module owns the line, so it now owns the number behind it and the rule that the number is only state where something renders it. The screen holds no flag and no counter; it asks for both and passes the reporter on. The reporter is stable, so the bridge host is never rebuilt for it. `isDevelopmentBuild` becomes a call rather than a module constant. A build flag never changes at runtime so this costs nothing, and as a constant the branch was unreachable to anything that did not set the global before the module loaded — which is why the production case could not be written at the screen at all. Also fixed, found while writing that case: the screen test's `usePageHostSnapshot` double returned a fresh object on every render, so the host effect's identity changed each time and the bridge host was torn down and rebuilt on every render of the screen, settling every pending request with it. The real hook holds the snapshot in `useState` and is stable. One object for the file now. This was masking the fold under test — the count reset to zero on every render — and every other case in that file was measuring a rebuild storm. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): price a screencast frame before encoding it (OTA phase C, C6.1) Round 2 on #21758, two lows. The encode is a base64 pass over the whole image and the window decides whether the frame can be posted at all, so deciding after encoding made a page that had stopped acking pay for every frame the shell then threw away — the reviewer's case is ten 300 KB frames against a closed window, 3 MB encoded and nothing sent. The size is knowable without encoding: base64 is ASCII, so JSON escapes none of it and the frame is its header serialized plus exactly the image's encoded length. `encodeBridgeScreencastFrame` is now built from that header rather than beside it, so the shape measured and the shape sent cannot drift, and the window arithmetic is one rule read before the encode and again on the frame that was. Exact, not conservative, so the drop diagnostic still reports the whole frame and the committed byte pin is untouched. Red-first with the real encoder wrapped in a counter: window full, ten frames, ten encodes before and zero after, with the drop count still ten. An over-cap frame likewise goes from one encode to none. A third case holds the other direction — two carryable frames still encode twice — so the fix cannot pass by encoding nothing. Second low: the dev-facts block sat outside the only `beforeEach` and left `routeGrants` and `client` mutated, inert only because it runs last. The shared setup moves to file level where the mutable dependencies actually live, resets both, and a case at the end of the file pins it — deleting the reset fails there and nowhere else, since nothing else runs after a case that mutates them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
f3bda1bf3e |
refactor(mobile): seam moves and the shared shell route guard for the source-control domain (OTA phase C, C4.1) (#21732)
* refactor(mobile): open PR sidebar URLs through the external-link seam (OTA phase C, C4.1) The three openers in the PR sidebar called `Linking.openURL` directly: a check's "open on the web", a comment's permalink, and a link inside comment Markdown. Inside the shell's WebView react-native-web routes that to `window.open(url, '_blank', 'noopener')`, which both shells refuse and which resolves anyway, so the tap reports success and opens nothing. Both C4 routes reach the sidebar, so both would have shipped that. The census is the point rather than the three edits. It derives the two future route closures through `mobileWebAppRouteClosure` and holds every module in them to the seam, so a module entering either closure later is ruled without anyone adding it here. Red first it named all three by `path:line`: CommentMarkdown.tsx:2, PRChecksSection.tsx:2, PRCommentCard.tsx:2, on both routes. The walk it runs was the third copy of one function, so it moves into the seam's own module beside the predicate that module exists to share, and the files and tasks censuses now call it too. It reports `path:line` where the copies reported paths; `reachesReactNativeLinking` keeps its name and its meaning and is now derived from the line list, so there is one rule. An empty offender list is empty in either shape, which is why repointing the two landed censuses moves nothing they assert. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): copy through the platform clipboard seam in review and conflicts (OTA phase C, C4.1) The two copy actions both C4 routes reach called `expo-clipboard` directly: the conflict section's refresh commands and the review sheet's notes. On the web that module is `navigator.clipboard`, which needs a secure context — the iOS shell serves the page from a custom scheme and Android from https, so the path works on one platform and silently not on the other. `useClipboardWriter` is the seam C2.4 landed for exactly that. Red first, the census named both routes: `ExpoClipboard.web.js` in each closure, and `src/platform/clipboard.web.ts` in neither. Both call sites also stopped ignoring whether the pasteboard took the text. The conflict section already returned on a throw, so the seam's rejection reaches an arm it had. `copyNotes` had none and its only caller is `void controller.copyNotes()`, so a rejection would have been unhandled with "Review notes copied" left on screen; it now catches and reports through the screen's own error line. That is the one behaviour change here and the reason `clipboard` joins its dependency array. Its suite mocked `setStringAsync` as resolving `undefined`, which the seam reads as a pasteboard that refused, so every copy would have gone down the new refusal arm unseen. The mock now resolves `true` and two cases pin both arms; mutating the catch away kills the refusal one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): take the source-control router from the handoff seam (OTA phase C, C4.1) The hub takes its router once, in the openers hook, and passes it down to the runners and the panel — so one `useRouter()` is this domain's whole reach into routing, and it was expo-router's own. Inside the shell's page that posts no `navigate`, so the hub's push to review would stay in the document whatever its grants, and its push to a native route would paint Unmatched over the page. Inert today: no C4 route is registered yet. `use-mobile-source-control-runners.ts` is the second case and the reason the rule reads value imports rather than identifiers: it named expo-router only to write `ReturnType<typeof useRouter>`, a value import in a type position that keeps the module in the graph. `RouteHandoff` is the seam's own name for that type. The census is C3.1's, and its walk moves to `src/navigation` rather than being copied a second time; each domain keeps only its own evidence, the list of modules meant to hold a router. Red first it named both modules on the expo-router rule and reported no handoff caller at all. The C2.9 hop census is unchanged and cannot move: its targets come from the call sites, and the derivation over this tree returns the same ten targets and the same 26 unresolved sites before and after this commit, byte for byte. Its `HANDED_OFF` pin is over registered routes, of which this adds none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the shell route guard out of the files domain (OTA phase C, C4.1) `src/files/mobile-file-shell-route.ts` was never about files: it parses a route against `BridgeInitRouteSchema` and builds the key a shell screen remounts on. It moves to `src/mobile-web-shell/shell-screen-route.ts` as `shellScreenRoute` / `shellScreenRouteKey`, with its test. The move is pure — with the rename applied and comments stripped, the old file and the new one diff to nothing. Three routes had grown their own copy of the call and two had none. The copies go: `agent-history` and `tasks` now ask the shared predicate, which is the same schema and the same fallback they already had. `index.tsx` had no guard at all, so a `.` or `..` host id was handed over and came back as "Update Orca to open this workspace" painted over the native list behind the switch; it now stays native. That is the one behaviour change here, pinned red first and killed by mutation. `web.tsx` keeps handing that route over on purpose and is exempt by name: its fallback is a redirect to the route the user came from, so the host's own verdict is the better answer there, which `mobile-web-shell-route.test.tsx` already pins. No `key=` expression moved; the three switches still key differently (host id, pathname, pathname plus params) and making them agree is a behaviour change for another PR. The census walks the route tree rather than a list, so a switch added later is held to both rules without being added here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mount the copy cases without a client instead of casting one (OTA phase C, C4.1) The changed-code gate flagged four type assertions on the two cases added with the clipboard seam: they stubbed an `RpcClient` the way the file's older cases do, and the gate reads changed lines. Copying reaches no client at all, so they mount without one, which is both cast-free and a truer statement of what the path needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): say what the censuses report and sort the red list by line (OTA phase C, C4.1 round 1) Round-1 folds, four wordings and one ordering. `externalLinkOffenders` said "every call site" and reports the line the name enters the module: a named import once, however many times the module calls `openURL`, because the import is what the rule is about and what has to go. Only a namespace import reports its uses, there being no single line to name. The docstring now says that. Its red list sorted the rendered strings, which puts `:10` before `:2`. It now sorts by path and then by line as a number. Pinned against a written fixture rather than the tree, because the case needs a module with sites either side of line ten and no module in a closure has to keep having one — the first fixture used lines 11 and 12, where both orders agree, and the mutation walked straight through it. `shell-screen-route.test.ts` still named the files screens in its describe after the guard stopped being theirs; it names what a switch does now. `router-seam-census.test-support.ts` excluded `.test-support.ts` from the walk, which the files census it was extracted from never did. Dropped, so both censuses walk the same set. Inert today: neither `src/files` nor `src/source-control` holds such a file, so it only decides the next one. The `web.tsx` exemption claimed a redirect "that looks like nothing happened". What was measured: adopting the guard there sends a `..` deep link through `Redirect href="/h/.."` to the host route, which this PR keeps native, so the developer lands on the host list with nothing said about why the page did not open. The route is `__DEV__`-only and the host's own failure screen is the better verdict. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): see every react-native alias, normalise the host id, surface a refused copy (OTA phase C, C4.1 CodeRabbit) Three bots findings on #21732, all real. **Every alias, not the first.** `reactNativeLinkingSites` found namespace imports with `exec` and inspected only the first binding, so a module importing the namespace twice and calling `Linking.openURL` on the second reported no site at all. It reads every alias now and counts a line once however many meet on it. Red first with exactly that fixture. **The host id can be an array.** `app/h/[hostId]/index.tsx` read it bare, and Expo Router answers a repeated key with one: `String(['a','b'])` is `a,b`, `encodeURIComponent` makes that the single segment `a%2Cb`, and the segment rule accepts it — so the shell opened a page for a host nobody has. Through `firstParam`, as the other four switches do. Red first it handed over `/h/host-1%2Chost-2`, and the empty-array case found a second one: `[]` is truthy, so a bare read built `/h/` and handed that over too; `firstParam` answers `''` and the route stays native. That import pulls the source-control screen state, and with it the lucide barrel whose `LucideProvider` re-export is the gap the web build patches, so the suite mocks the barrel as the other suites do. It moves no page closure: the closure resolves `index.web.tsx`, which this does not touch, and the index route still measures 3426 modules, 289 local, 22 families. **A refused copy said nothing.** `PRConflictingFilesSection` caught the rejection and returned: no tick, no message, a tap indistinguishable from one that copied. The label now carries the third state, reusing the tasks page's own wording for it, and the component has its first test. Mutating the failure arm away reds it. Its prop narrows to `Pick<PRInfo, 'mergeable' | 'conflictSummary'>`, which is what it reads and what let the test drop a cast the gate flagged; every caller holds a full `PRInfo` and satisfies it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the censuses' subjects as code, not as text (OTA phase C, C4.1 round 2) Round-2 additions. A separate commit because `d4b14d54e5` was already made and this lane does not amend. **The seam walk parses now.** Matching `X.Linking` in the text named it inside a comment that talks about it and inside a string that quotes it, and the named-import regex did the same for a commented-out import. Checked against the previous implementation, all three fixtures were red there: the comment case reported lines 2 and 3, the string case reported the string's line beside the real call, and `// import { Linking } from 'react-native'` reported line 1. The walk builds a `SourceFile` and reads import declarations and property accesses, so comments and strings are gone by construction and the quote styles stop being a special case. Cost measured on the three closure censuses: 3.3 s, unchanged. **The route census reads the call, not the import.** A switch that keeps the import while the call goes — deleted, or moved behind a branch that never runs — looked exactly like one that asks. It now needs both, proved by mutation: dropping `shellScreenRoute(` from `tasks.tsx` while leaving its import names `tasks.tsx`. A fixture carries the same rule in isolation, since every switch in the tree calls what it imports and the case would otherwise be unfalsifiable against it. **And recognises a switch by its import** of `MobileWebShellScreen` rather than by `<MobileWebShellScreen` in the text, so an alias or a line break the formatter chose cannot hide one and a comment cannot invent one. The `app/h/[hostId]` root stays written out: deriving it from the manifest is not a one-liner from here, the manifest being an `.mjs` this test reads as text. What ties the two together instead is a new case asserting every registered pathname starts with that prefix, so a page route outside it fails rather than going unwalked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the imported name, not the local one (OTA phase C, C4.1 CodeRabbit) `import { Linking as NativeLinking } from 'react-native'` went through the census untouched: the walk compared the specifier's local binding, which is `NativeLinking`, while the imported name lives in `propertyName` when a specifier renames it and only in `name` when it does not. Reproduced before the fix — the aliased import with a call beside it reported no site at all. Reading `propertyName ?? name` closes it in both directions. A module that renames `Linking` is named at its import line like any other, and a module that imports `View as Linking` is no longer named for a local binding that reaches nothing. The second was a false positive the old comparison had by construction. One more of the same class, found while checking and verified rather than assumed: `import RN from 'react-native'` typechecks in this project (tsc accepts it), and a default binding is the whole namespace exactly as `* as RN` is, so `RN.Linking.openURL` through it was invisible too. The default binding joins the alias set, which already reports uses rather than the import. Four fixtures. Three red on the previous walk: the renamed import, the local-only `Linking`, and the default import. The fourth — an alias imported that never reaches `Linking` — passed before and is here to hold the other half of the rule, that importing react-native is not itself the offence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): parse each module as its own kind, and read re-exports (OTA phase C, C4.1 CodeRabbit) Two ways a module reached `Linking` past the census, both reproduced before the change. Every file was parsed as TSX. In a `.ts` module `const id = <T>(value: T) => value` is a generic arrow; as TSX it is an unclosed JSX element, and the parser folds the rest of the file into the error node. A `RN.Linking.openURL` after one reported nothing, and so did the same call with its import above the arrow. The file name goes into the parse now and TypeScript reads the kind off the extension; `externalLinkOffenders` passes the real path, which it had all along. `ExportDeclaration` was never inspected, so `export { Linking } from 'react-native'` put the name back in reach of anything importing that module while the census saw an import list it was not on. All four shapes are read — named, renamed, `export *` and `export * as` — and reported at the export statement, which is the line to delete exactly as an import is. A re-export of another name, or of `Linking` from somewhere that is not react-native, stays unnamed. Seven fixtures. Five red on the previous walk: the `.ts` generic arrow and the four re-export shapes. The two that pass before and after hold the other half, that re-exporting is not itself the offence. The named-import and re-export clauses read `propertyName ?? name` through one helper rather than two spellings of it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
65cde9bb80 |
fix(mobile): name the source-control Back controls and split the dock's Close from Back (OTA phase C, C4.3) (#21739)
* fix(mobile): name the custom-key drawer's Back for the accessibility tree (OTA phase C, C4.3) `CustomKeyModal`'s Back is a bare `Pressable` with a label and no role, so a screen reader has nothing to announce it as. It is reachable today from the session sheets and the terminal shortcut settings, so this is a gap now, not only inside the page. It surfaces here because `screenTree` takes a screen's directory: C4.4 registering the review route puts the whole of `src/components` under the Back rule. Fixing it in the PR that registers would make a route entry carry unrelated accessibility work, so the census gains a case that holds the arriving trees to the same two rules before the rows land. Red first it printed exactly what the rule would: src/components/CustomKeyModal.tsx:191 role=none label=Back Once `PAGE_SERVED_SCREENS` has the two rows, `CONTROLS` covers these trees and the new case becomes a second reading of the same thing; it says so. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): split the source-control header's Close from its Back (OTA phase C, C4.3) One `Pressable` served both modes — `onPress={onBack}` with a conditional label, `X` docked and `ChevronLeft` full-screen. The Back census reads a control by what its handler does, so it sees that one as a Back and then requires a label starting with "Back", in a mode where the control dismisses the dock beside the terminal. The cheap way to go green is to call a close "Back", which satisfies the rule by making the wording wrong. So the modes become two controls. Embedded presses `onClose` and is named "Close source control"; otherwise it presses `onBack` and is named "Back to session". Both carry the button role. The panel stops choosing by mode and passes both handlers; the dock keeps exactly the behaviour it had, its dismiss still `onRequestClose` falling back to a pop. Probed before it was written: run through the census's own predicate, the post-split shape yields one back control rather than two — `onClose` matches neither the handler pattern nor a declaration this file holds, being a destructured prop — so the Close is invisible to the rule and the presence precondition still holds on the Back. The component test is what the census cannot do. Nothing else pins this: no golden names this component and no parity census covers `src/source-control`. Red first against the single control, both cases failed on the missing role. And renaming the Close to "Back to close source control" — the gaming this split exists to prevent — reds the component test while leaving the census green, which is the whole argument for having both. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): assert a Back per arriving tree, and name the refresh control (OTA phase C, C4.3 round 1) The arriving-trees presence case counted controls over the union of both trees, so one tree answered for the other. Per tree now, in the shape the block above it already uses per screen module. Red first, with the mutation round 1 named: rename the route branch's handler to `onDismiss` and its label to `Return to session`, and the only Back in `src/source-control` disappears. The per-tree case names that tree. The same mutation against the union count passes all six cases, which is what the change is for. The refresh control had a label and no role, so react-native-web renders a `div` carrying `aria-label` and a screen reader announces no control. Its two siblings in this header already carry one. Two claims in my round-1 report were wrong and I am the reason the body carried them. The Close pressing `onBack` reds the census's label rule as well as the component test, not the component test alone; and no fixture of the post-split shape exists — the shape is read from the real file. Both were stated from reasoning rather than from a run. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert an arriving Back per screen, not per tree (OTA phase C, C4.3 round 2) Round 1 moved the arriving-trees presence assertion from the union to each tree, which was not far enough. `src/components` holds two Backs, so the tree answers for both: renaming `MobileDiffReviewHeader`'s handler to `onDismiss` and its label to `Return to session` leaves every case in the file green, with `CustomKeyModal.tsx:191` standing in for the screen that just lost its Back. Reproduced before the change — six passed with the review header's Back gone. Per screen module now, which is what the table above already does and for the same reason its docstring gives: a directory with more than one control cannot say which one a rule was written about. The two modules are named and the trees derive from them, so the pair C4.4 adds to `PAGE_SERVED_SCREENS` is the same pair spelled once here. Both mutations red on the new case and name the file: the review header's rename names `src/components/MobileDiffReviewHeader.tsx`, and round 1's source-control rename still names its own, so this does not trade one cover for another. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
cef4416115 |
fix(mobile): name the narrow host header's controls and gate the drawer's hardware back on web (OTA phase C, C2.10) (#21729)
* fix(mobile): name the narrow host header's controls The header renders two toolbars and the phone sees the narrow one, whose controls carried neither a role nor a name. A screen reader could not find them, and C2.9's render check could only assert their absence at 390 px. The wide toolbar already names every control from the same state, so the fix is to say the same thing rather than invent wording: filter, sort, group, accounts, tasks and the search toggle take their wide sibling's role and label expression verbatim. The census names a seventh site the plan did not: the Reconnect button in the status bar above both toolbars, which is shared rather than narrow and has no wide sibling. It announces through its Text child today, so it takes the role and the string it already renders. No layout, style, handler or order changed; the diff is accessibility props only. The census parses the file with the TypeScript API and rules that every Pressable carrying an onPress has a button role and a name, and that a control both toolbars render is named the same way in both. It keys the pairing on the handler, because that is what makes two elements the same control, and asserts each shared handler is found exactly twice, so a control deleted from one toolbar cannot leave the naming rule comparing a group of one with itself. Red first, naming all seven sites by path and line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the right drawer arming hardware back on web React Native Web logs "BackHandler is not supported on web and should not be used." and returns an inert subscription, so inside the shell's page every open of this drawer put that line on the console and armed nothing. The gate is the one mounted-bottom-drawer and the file preview already carry, with the same comment stating the degradation: there is no hardware back in a WebView, and the shell owns the one the phone has. The drawer had no render test. This one mocks react-native, the safe area, gesture handler and Reanimated the way the bottom drawer's hand-back test does, and reads the call rather than the console: on iOS and on Android the handler is registered once for 'hardwareBackPress' and released when the drawer hides, and on web it is never reached. The three native cases are the control that keeps the web case honest; they passed before the fix, which is what makes the single red meaningful. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): type the right drawer test's element helper The tests-typecheck ratchet was red on the previous commit: the drawer's props declare `children` as required, so passing it as createElement's third argument left no overload matching. It is a prop here, and the helper answers a ReactElement rather than a return type borrowed from createElement. Test files sit outside `tsc --noEmit`, so only the ratchet sees this; it is the gate that exists because a type-level pin in an unchecked test proves nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the right drawer test in JSX Lint was red on the previous commit and I ran it in the same command as the commit, so it landed: passing `children` as a prop to satisfy the type checker is exactly what react(no-children-prop) refuses. The canonical form settles both, so the test is JSX in a .tsx file and the drawer takes its body as a child again. The StyleSheet mock's generic needs the trailing comma a .tsx file requires. Re-proved in this form: with the web gate removed the web case fails and the three native cases still pass. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): derive the header's naming groups, and read a spread as unknown Round 1, four folds. The drawer's comment claimed the console line was observed inside the shell's page. It was not: the drawer's one caller is the review screen, whose route C4 serves, so no page closure reaches it today. The gate is pre-emptive and now says so in its own words rather than borrowing the bottom drawer's sentence. The naming rule iterated a hand-written list, so it only ever compared the six controls both toolbars render. Giving the two `actions.openFloatingWorkspace` sites different labels left the census green. The groups are derived from the discovered controls now, keyed by the handler text, so any handler this header presses from more than one place is compared and the failure prints both names. The declared list stays as the precondition it always was: each of the six is found exactly twice, which is what keeps the derived rule from holding vacuously over a file with no repeated handler. The scan read `Pressable` only and dropped any control whose `onPress` read as empty text, which is what a spread reads as. It reads `TouchableOpacity` too now, and a spread answers unknown rather than absent: a control whose handler or whose accessibility props arrive through one is kept, fails both rules, and prints `spread` rather than `none`, so it can never be mistaken for a control the scan judged. Red first on all four: the reviewer's disagreeing-label mutation, a spread over the a11y props, a spread over the handler, an unlabelled TouchableOpacity, and a shared control deleted from one toolbar. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a braced-empty label as unnamed, from one shared reader Round 2, two folds. Only a bare `""` or an omitted attribute read as unnamed, so `accessibilityLabel={undefined}`, `{''}` and an empty template all left a control with nothing to announce and the census green. Reproduced on both Tasks sites with each of the three shapes before the fix. The reader unwraps a braced expression now: a string or a no-substitution template answers its own text, and the identifier `undefined` answers empty, so all three read as unnamed. That reader was a near-verbatim copy in both censuses, which is how one of them could have gained this rule and the other kept the hole. It lives in one module under mobile-web-shell now, named for what it reads and typechecked by mobile tsc rather than by the ratchet alone. Both censuses import it and neither changed an assertion; their diffs are the deleted copies and the import. Red first, five mutations: the three empty shapes on both Tasks sites here, and `{undefined}` and `{''}` on the tasks Back, which the page census now catches too and did not before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
335d111265 |
fix(mobile): name the Back control in every page-served screen (OTA phase C, C2.8) (#21715)
* fix(mobile): name the Back control in every page-served screen The C2.7 device proof could only reach the tasks Back by tapping the native control's coordinates: inside the shell there is no native chrome behind it, so a bare Pressable is absent from the accessibility tree and a screen reader has nothing to announce. The agent-history panel's Back carries a label but no role; the tasks one carries neither. Both gain `accessibilityRole="button"`, and the tasks one the app's own wording, `accessibilityLabel="Back"` — the spelling its thirteen siblings use, including the agent-history panel this screen sits beside. The host screen's Back already carried both and did not move. The new census is a rule rather than a count: it finds every Back affordance under the screen tree each page route serves — a Pressable rendering a ChevronLeft that either carries the back-button style or calls back — and requires a button role and a Back-shaped label on each. Its coverage assertion reads MOBILE_WEB_PAGE_ROUTES, so a route registered without a screen entry fails here, the way the flag census fails for a switched route. Red first on both offenders by name. Two tasks parity hashes move, and only the two a JSX prop must move: `semantics` 3,272 -> 3,274 for the widened host signature and the two new runtime strings, and the render-token stream 35,195 -> 35,203 for the eight tokens the two attributes are. Hooks, statements, declarations and styles hold, which is the evidence that nothing executable changed. Recorded in the test's comment. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): judge a Back control by what it does, and name the files pair Round 1 found the census discriminating on the ChevronLeft glyph, which separates nothing: dismisses sit in the same header slot with the same back-button style, so the rule claimed a dismiss and told it to be called Back, while the explorer's Close escaped only by drawing an X. The predicate is now the behaviour. A Back control is a Pressable whose press handler reaches a back call — a `back()`, `goBack()` or `onBack()` call, a handler named for one, or a local handler whose declaration makes one — or whose label already says Back. A control matching neither is outside the rule whatever it renders. The reviewer's mutation, a dismiss with the back-button style wrapping a ChevronLeft behind `onDismiss`, is claimed by the old predicate and passed over by this one, which is the whole of the difference. A spread now reads as unknown rather than absent, so a Pressable that hides these props behind one is a Back control that fails both rules instead of a control the scan never sees. No Pressable in the five trees spreads today, so the branch is inert and load-bearing. C3.1 registered the files explorer and preview, so the coverage assertion went red on its merge, as designed. Both screens join PAGE_SERVED_SCREENS and both Back controls gain `accessibilityRole="button"`; they already carried `Back to session` and `Back to files`. The tasks Back gains the `hitSlop={8}` its four siblings carry, so its touch target is no longer the glyph alone. Nine mutations: the dismiss stays out; a dropped role on each of the five controls and a dropped label on the tasks one go red by name; a spread fails both rules; a dropped table entry fails coverage; and dropping the host screen's label — its only detector, since `actions.leaveHost` is opaque — empties that tree and fails the presence assertion rather than passing in silence. Two parity hashes move for the one added prop and no count does: `semantics` holds at 3,274 lines with one changed, the host signature gaining `hitSlop`, and the render-token stream gains that attribute's four tokens, 35,203 -> 35,207. Recorded in the test's comment. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): assert a Back per page route, not per screen directory Round 2 found the presence assertion reading directories while two routes share `src/files`, so the explorer's Back answered for the preview's as well: rewriting the preview's control to `onPress= {handleClose}` with `accessibilityLabel="Close"` left that route with no accessible Back and the suite green. Reproduced exactly, then made red by this change, which names the route and the module it failed in. Each PAGE_SERVED_SCREENS entry now names the module that renders its route's Back, and presence is asserted per entry. The rules still read whole trees, so a Back added beside a screen is ruled as well as the screen's own; the tree is derived from the module's directory rather than declared twice. The failure prints the offending list instead of comparing two values a reader cannot tell apart. The doc comment claimed the preview was carried by the label half of the predicate. It is not: `requestBack` is named for what it does, so the preview is found behaviourally like the explorer, the tasks screen and the agent-history panel. Dropping the label from each of those three leaves it detected and failing the naming rule, which is the evidence. Only the host screen depends on the label half, because `actions.leaveHost` is a member access this rule does not follow. The admitted residual gap stays admitted and the predicate is not widened for it: a second Back control in a screen that already has one, with an opaque handler and a label that does not say Back, is still outside what this rule sees. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ac024d4f05 |
feat(mobile): serve the files explorer and preview from the page (OTA phase C, C3.1) (#21710)
* refactor(mobile): take the files screens' router from the handoff seam Inside the shell's page a screen is one document standing in for one screen, so a target the page does not render has to be handed back to the app that does. `useRouteHandoff` is where that decision lives, and its web sibling is the only thing that makes it; both files screens held expo-router's own `useRouter`, so on the web the explorer's Back and the preview's Back would post nothing and a target outside the page would paint Unmatched over the page it is on. Natively this is the same object — `route-handoff.ts` is `useRouter()` — so no behaviour moves here, and `back()` stays expo-router's until the navigate-back verb lands and the seam starts wrapping it. A census rather than a behaviour test: neither screen's own tests can see the difference, because a push that is never handed off still works for a target inside the page. It walks this directory, refuses a value import of expo-router, and names the two screens that must hold a router so a walk that found nothing fails instead of passing empty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the shell stand in for the two files routes Both route files take the index.tsx shape — flag, MobileWebShellScreen, native screen as fallback — and both gain the `.web.tsx` sibling that shape forces. Inert until the manifest lists these routes: the shell answers `native-route` for a route the bundle does not name, which is what `fallback` renders, and the flag is `__DEV__`-only besides. Listing them waits on C2.3 and C2.5. The sibling is not a precaution. The manifest defers every route behind `import()`, so a native-only route module is invisible until the page opens that route; the render check now opens both and, without the siblings, painted `expo-modules-core.requireNativeViewManager is not available on web` instead of the screen. That is also why the two cases render the route rather than asserting a file exists. The file path never becomes a path segment: only `hostId` and `worktreeId` are spelled into the pathname, encoded, and everything else — `relativePath`, `absolutePath`, `cwd`, `pathText` — is a param, which is how a `/`, a space or a `..` stays out of the segment vocabulary the bridge holds a route to. The preview render case proves the round trip on `docs/my notes/readme.md`. `mobileFilePreviewShellParams` drops a param the normalizer left `undefined` rather than sending it empty, because the page reads these back through useLocalSearchParams where `line: ''` and no `line` are different screens. Its test drives the normalizer rather than a hand-written literal: the literal omits the key entirely, so it held with the filter removed. The preview case also records what React Native Web says out loud — BackHandler is inert on web, so Android back inside the page skips the unsaved-draft prompt. Named in the assertion rather than filtered out, so closing it is a change to that line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ask about an unsaved draft in the screen, not through Alert React Native Web's `Alert` is `static alert() {}`. Inside the shell's page that made Back with an unsaved terminal-artifact draft a button that did nothing at all: no prompt, because the dialog is a no-op, and no navigation either, because the code took the branch that shows one. Silently, with nothing on the console. The prompt is now a row under the header. Not `ConfirmModal`, which every other confirm here uses: that is a `BottomDrawer`, and C1.9 has Reanimated's animated styles never reaching the DOM node on WKWebView, so on iOS in the page the drawer parks off-screen and Back would be dead a second way. This paints the same on every platform with no animation behind it. Hardware back is registered natively only. React Native Web's `BackHandler.addEventListener` logs "BackHandler is not supported on web and should not be used." and hands back an inert subscription, so the guard never armed there regardless; the render check asserted that console error on main and now asserts none. The degradation is real and stated rather than hidden: Android back inside the page pops the native stack without asking, and the page's own Back control is where the question lives. The decision moved to a hook so it is testable without a screen: the prompt also drops itself when the draft it was about is saved or reverted, which is a state `Alert` had no way to be in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep expo-haptics' DOM shim out of the page expo-haptics has a web build, and with no `navigator.vibrate` — iOS Safari, which is the WebView the page runs in — it fakes a haptic by appending a hidden `<label><input type="checkbox" switch>` to `document.head`, clicking it, and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to exactly that stray click, and the file explorer calls `triggerSelection` on every row tap, so C3 is the first domain to fire it per tap rather than per long press. `haptics.web.ts` answers the same five names with nothing. A phone holding the page is a phone whose native app is right there with the real haptics, and a missing tap feedback is worth less than a tap that does not register. The test reads the shipped bytes rather than the import, because that is the claim: with the override removed the bundle carries `ariaHidden` and `pointer: coarse`; with it, neither, nor the `setAttribute("switch"` that does the clicking. Not `navigator.vibrate` — react-native-web's own Vibration export calls that and touches no DOM until something invokes it, which cost this test one wrong red before it was narrowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the files routes native when the page could not be given one A file path is a param, so `/`, spaces and `..` all cross safely — but `BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 and a Windows long path is not bounded by anything the user cannot exceed. The symptom is not the blank document the design predicted, and the correction matters: `bridge-host.ts` already parses the route against the page's own schema and drops it to `null` when it fails, so `init` arrives naming no screen and the page paints "Update Orca to open this workspace" — a wrong message about a fine app, over a native screen that works. Deciding before the switch instead leaves the route native, which is where every route starts. The schema is the predicate rather than a copy of its bounds, so the rule cannot drift from the half that matters, which is the half the page reads. The same call also refuses a `worktreeId` the segment rule will not route: `..` survives `encodeURIComponent`, which is the C1.8 class. The tests assert the schema really refuses each input before asserting the guard does, so neither case can pass by being impossible. This belongs in the shell beside the schema; it is in the files domain while the contract files are the C2 lane's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin what keeps a file path out of the route vocabulary Seven shapes, one case each rather than a representative: a plain path, a space, a dot segment, an already-encoded slash, a fragment, non-ASCII, and an absolute path. Each is checked in the two directions a path travels — the href the shell writes into the page's history, and the href the page would hand back — for both the pattern accepting it and the path coming back out of the query unchanged. The counterfactual is in the file: the same paths spelled as a segment are refused. Without that, the cases above would hold for a rule that was never doing any work. Mutating `stringifyRouteHref` to join its query by hand instead of through `URLSearchParams` fails three of them. Also fixes two new test files the tests-typecheck ratchet caught: the partial `react-native` mock needs a typed `addEventListener`, `act` will not take a callback that returns a value, and `findAllByType('Pressable')` does not typecheck against `ElementType` — the neighbouring files that do it are grandfathered, so the tag comparison goes through a helper instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): derive the discard prompt instead of clearing it in an effect Both changed-code gate findings, which the lane had not run until the last commit. React Doctor is right: the effect that cleared the prompt when the draft went away adjusted state after a prop changed, so a save landing while the prompt was up painted one frame still offering to discard nothing. The prompt is now `asking && hasUnsavedDraft`, which cannot be stale by construction, and the test that covers it passes unchanged. The hoisted mock's `as` on a string literal is gone too: the literal narrows on its own and the tests reassign it, so the holder is annotated instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): add the files routes to the hybrid shell flag census The census pins every file that reads `useMobileWebShellEnabled`, because a reader nobody listed is how a dark feature stops being dark. C3's two routes are deliberate entries: each has a native screen behind it as `fallback`, and each is inert until the manifest lists the route. Found by the full mobile suite rather than by the files subset this lane had been running per commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve the files explorer and preview from the page The last C3 commit: both routes join MOBILE_WEB_PAGE_ROUTES, and the shell starts rendering the page for them on a phone with the dev flag on. Grants are not the same for the two, and the difference is the point. Both take `navigate` (Back pops the native stack, and the explorer's rows open the preview beside it) and `storage` (the shared components the host layout renders above them). Only the preview takes `externalLink`: a Markdown preview renders links and `MobileMarkdown` opens them through the platform seam. The explorer does not, and measuring is what says so rather than reading. Every page route reaches `external-link.web.ts` — `/h/[hostId]` and agent-history included, both granted nothing for it — because the protocol wall in the shared host layout imports it. So closure membership is not the oracle for a grant; the question is whether the route's own screens call it, and only the preview's do. `MobileMarkdown` is in the preview closure and absent from the explorer's, which the census now asserts in both directions. Neither route writes a clipboard, so neither takes `native.clipboard.write`; the census pins that as the absence of both `ExpoClipboard.web.js` and the clipboard seam, with the tasks closure as the control that the probe can see one when there is one. The seam predicate moved into a module both censuses import rather than being restated per series: two spellings of one rule drift, and this one is a regex. Red-first: both manifest assertions failed on the new entries before they were updated, and routing `MobileMarkdown` around the seam fails the preview's census while leaving the explorer's passing, which is the asymmetry the grants encode. Closure sizes as the page ships them, extensionless so the `.web.tsx` is what is measured: explorer 3439 modules / 302 local / 10 under src/files, preview 3667 / 331 / 20. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the files route's ids as one value and key the shell on them Two round-1 findings, both reproduced before the fix. A repeated query key reaches `useLocalSearchParams` as an array, and the explorer read `hostId` and `worktreeId` bare. `String(['a','b'])` is `a,b`, so the template built `/h/host-a%2Chost-b/files/wt-1%2Cwt-2` — a single segment the bridge's rule accepts, and the shell would open a page for a host nobody has. Read through `firstParam` now, as the tasks and agent-history switches do. The preview already went through `singleParam` and is unchanged. Neither switch keyed `MobileWebShellScreen`, where `index.tsx`, `tasks.tsx` and agent-history all do. A host captures the grants its session opened with, so a screen reused across a route change keeps authorising frames under the grants of the route the page has left; only a remount drops that bridge. Both are keyed on the route pathname now, with agent-history's reason. The new route test is the agent-history one's shape. It caught both: the array case landed on no route at all, because `name` was an array too and the schema refuses a non-string param value, and the two lifecycle cases saw a prop update where a remount was owed. It also needs agent-history's `lucide-react-native` mock, since `firstParam` lives in the source-control barrel. `name` is now omitted when empty rather than sent as `name=`, matching the two switches beside it: an absent label lets the panel derive its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): confirm a discarded draft with the app's own modal Round-1 findings 3, 4, 5 and the minor one. **ConfirmModal, not the bespoke row.** The row existed because C1.9 had Reanimated's animated styles never reaching the DOM node on WKWebView, which left every BottomDrawer parked off-screen. C1.10 (`b7c06900e2`, an ancestor of this branch) fixed that with a dependency array on the mapper hooks, and the drawer render check now holds it on WebKit as well as Chromium. With the reason gone the row does not stand on its other merits: `Alert.alert` was modal on native before the page existed, and the row quietly changed that for phones too, so the app's own confirm is both the idiom and the closer behaviour. `MobileFilePreviewDiscardPrompt`, its test and its thirty style keys are gone; the hook's state machine and its tests are unchanged. **The encoding test claimed more than it pinned.** Hand-joining the query reds only three of the seven shapes; `docs/readme.md`, `../etc/passwd`, `docs/日本語.md` and `/logs/run.txt` are encoding-neutral in the query, whose pattern half is `[^#\s]*` and admits a slash, a dot segment and non-ASCII verbatim. Rather than narrow the claim in a comment, the split is now pinned by behaviour: each neutral shape must survive the query unencoded, each load-bearing one must not. Moving `docs/readme.md` between the lists fails it. **The manifest comment named one shared-layout opener and there are two.** The New Workspace source field, which the sidebar renders on a wide layout, opens a URL through the seam as well. Both are the shared layout's and every `/h` route reaches both, `/h/[hostId]` included with no `externalLink`, so the tablet tap is dead on all of them — recorded here as pre-existing rather than fixed, since the grants do not move. **Minor:** the dot-segment case in the guard test now asserts the schema refuses the route before asserting the guard returns null, as the length case does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop every page drawer logging a BackHandler error when it opens Round-2 findings. **The registration belongs to the drawer, and that is where the guard went.** `mounted-bottom-drawer.tsx` armed `hardwareBackPress` whenever a drawer was visible and interactive, with no platform check, so the hook's claim to have dropped that console line held only while its prompt was closed — and every page drawer since C1 has logged it on open. Platform-gated at the drawer now; the hook's comment says so rather than claiming the credit. **Nothing had ever opened a modal in a browser.** The render check next door mounts both files routes and reads what they paint but taps nothing, so `ConfirmModal` inside the page — a BottomDrawer, so Reanimated, a portal and a gesture handler — was unproved. A new render file loads an editable terminal artifact through the harness's scripted reply, edits it, taps the page's Back, and asserts the prompt's title is up and no BackHandler line is on the console. Red first on exactly that line; the prompt itself painted, which is also the first proof on a browser that C1.10's fix carries a real drawer in the page. A second case answers Stay and checks the draft survives. Its own file rather than the render check's, which is at 482 of the 600-line cap; registered in pr.yml. **The encoding rule was stated wrong.** Two rules decide it and neither is about paths: the pattern's query half refuses whitespace and `#`, and `URLSearchParams` is form-urlencoded, so it reinterprets `&`, `+` and a valid `%XX`. `a+b.ts` reads back `a b.ts` and `a&b.ts` reads back `a`, so both are load-bearing; `a=b.ts` and `a%b.ts` are not, because only the first `=` splits the pair and a lone `%` begins no escape. A newline joins the load-bearing list as the refused shape rather than the altered one. **The web sibling read its params bare** where the native one uses `firstParam`. Not reachable — the page only arrives through `init.route`, whose params are already `Record<string, string>` — but the two files are meant to be one screen. The preview keys on the pathname alone, and the comment now says why that is enough: every caller in this tree pushes. Closures after this: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. The explorer grew two modules because its web sibling now reaches `firstParam`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the explorer the grants the preview needs, and key on the route Bot findings, one of them a real gap. **Pullfrog is right, and my grant oracle was half a rule.** Grants resolve once, from the route the shell opened: `grantsForRoute` reads `session.routePathname` and `init.grants.native` carries the answer for that session. The explorer's rows push to the preview, and because the preview is a page route that push stays inside the same document — no second `init`. So a preview opened that way runs under the explorer's grants, and a Markdown link in it was refused by `notifyExternalLink` with nothing on screen to say why. "Does the route's own screen call it" was right for a route's own screens and wrong for the routes it reaches in-page, so the explorer now declares `externalLink` as a transitive grant, with the comment saying that rather than claiming it opens links. The census pins the pair as a superset; removing the grant reds it. **The seam regexes matched one quote style.** A double-quoted `react-native` specifier walked past both censuses unseen. Both styles now, with the predicate tested directly for the first time. **The discard request outlived its draft.** `asking` stayed set after a save or a revert, so the next edit re-showed the prompt with no Back request behind it. The request is now dropped when the draft it was about goes, adjusted during render rather than in an effect — the shape React Doctor named in the round-1 fold. Red first: save with the prompt up, edit again, prompt is back. **CodeRabbit's keying comment is a correctness point, not the question I answered.** The page learns its route exactly once, out of `init`, so a same-path param change — another file in the same worktree — left the shell mounted and the page still showing the file it was opened on. My comment claimed "the screen reloads the preview from the param either way", which is true only with the shell absent. Both switches key on the whole route now, params included; two tests cover the same-path case and both red on a pathname-only key. `build-mobile-web-app-bundle.test.mjs` hit 601 of its 600-line cap on the way, so the two manifest assertions now share one expected list instead of repeating it. Closures unchanged: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the seam test import the module it is testing Round 3. **The blocker is mine and the reviewer's diagnosis is exact.** The seam predicate test imported an absolute path into this lane's worktree. On CI that module does not exist and it takes the whole `config/scripts` suite down; here it resolved to the same file by accident, so the test was green against a tree rather than against the checkout — which is why reverting the double-quote fix left it passing and the predicate untested. Relative now, and proved: reverting the fix in place reds both double-quoted cases, which is the first time this test has failed for the right reason. Every file this PR touches is grepped for `/Users/` and `orca-lanes`; none carries a path. **Three comments outlived the grant change.** The two lists became equal when the explorer took `externalLink`, so "longer than the explorer's" and "declared with different grants" were both false. Corrected to what is actually true: the lists are equal and the reasons are not — the preview has its own consumer in `MobileMarkdown`, the explorer has none and declares the grant because its rows push to the preview in-page. **The duplicated serializer is pinned rather than imported.** `shellRouteHref` lives in `page-bootstrap.ts` beside the page's RPC client and its document channel, so a native route file importing it would pull both into the app. The copy stays, and a test asserts the two agree on three routes; dropping the empty-search branch reds it. **Recorded, not fixed:** the sidebar `HostScreen` pushes to `/h/<id>/tasks` through the handoff, which is local, so on a tablet the tasks page runs without `native.clipboard.write` from any page route and its copy actions refuse silently. Pre-existing since C2.1 for the worktree list and agent history. Named in the explorer's manifest comment as the known remaining hop, with the fix being a handoff rule in its own PR. The equality pin needed `it.each<BridgeInitRoute>`: the inferred table is a union whose members carry `?: undefined`, which the ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
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
|