Commit Graph
87 Commits
Author SHA1 Message Date
OrcaWinandOrcaWin 85522e89b8 test: remove low-value theme and copy suites (#36140)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-29 16:13:19 -07:00
Neilandzhangxaochen 8a4bc559e6 perf(mobile): skip line stats when loading Source Control status (#36061)
* perf(mobile): skip line stats when loading Source Control status

Mobile Changes rows never render per-file line counts, so the default
git.status line-stats pass (up to two `git diff --numstat` subprocesses
plus untracked file reads) was wasted host work on every load. Hosts
that predate the param strip the unknown key and behave as before.

Co-authored-by: zhangxaochen <3416677+zhangxaochen@users.noreply.github.com>

* test(mobile): re-record Changes-load goldens for the no-line-stats status read

Co-authored-by: zhangxaochen <3416677+zhangxaochen@users.noreply.github.com>

---------

Co-authored-by: zhangxaochen <3416677+zhangxaochen@users.noreply.github.com>
2026-10-29 13:36:57 -07:00
OrcaWinandOrcaWin c40f32163e fix(mobile): translate coded generation failures and counted chat copy (#35923)
- Commit-message generation keeps the host's optional errorCode, errorParams and
  errorDetail and translates every known text-generation code, matching desktop's
  rules: unknown codes, older hosts or missing values show the host's English
- Background tasks, subagent groups, the model label and agent-session write
  notices use localized mobile adapters instead of the shared English sayers

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-29 04:39:23 -07:00
OrcaWinandOrcaWin a99bc36593 feat(mobile): localize tasks, new workspaces and source control (#35646)
* feat(mobile): localize tasks, new workspaces and source control

Moves the last ~670 strings (task lists and boards, issue and PR sources, the
new-workspace flow, and source control) to the translation API, with all ten
translations. What remains in the coverage baseline is reviewed non-copy: key
names, schema errors and log lines.

- Source control section titles recompute on a language change

* fix(mobile): align catalog terminology across screens

A whole-catalog pass per language reconciled terms that differed between batches
(host/desktop, dictation, transcripts, address form) with the glossaries and the
desktop catalogs. The page bundle verifier now reports catalog bytes separately.

* fix(mobile): keep fetched history and source error slugs language-independent

History rows are derived from the fetched page at render so relative times follow a
language switch; quality-gate follow-ups for the tasks and source control migration.

* fix(mobile): name Android notification channels in the UI language

* chore(mobile): drop catalog entries for removed source-control copy

* fix(mobile): re-localize the composer source hook on current main

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 21:11:04 -07:00
OrcaWinandOrcaWin d8eef80bf4 feat(mobile): localize sessions, terminal, agent chat, browser and files (#35553)
Moves ~970 strings in session screens, the terminal, agent chat (including the
copy mobile used to render straight from shared English records), the browser pane
and the file explorer to the translation API, with all ten translations.

- Chat tool-run sentences, task lists, receipts, notices and turn status take
  translated words; the shared sentence joiner accepts an optional continuation so
  German nouns and Turkish casing survive mid-sentence (desktop unchanged)
- "Used the browser" vs "… 21 times" and resolved PR comment headers use explicit
  keys instead of plural rules, which would drop the count for Russian 21, 31, …
- Action sheet rows carry stable ids instead of being keyed by translated labels
- Rename inputs keep the raw title rather than a translated placeholder

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-29 03:55:41 +00:00
OrcaWinandOrcaWin 34536771a7 perf(mobile): skip projection for abandoned diff replies (#34873)
* perf(mobile): skip projection for abandoned diff replies

* test(mobile): consolidate abandoned diff projection regressions

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 20:07:45 -07:00
OrcaWinandOrcaWin 5ed3c8c072 feat(mobile): localize shared components, pull requests and diff review (#35532)
Moves ~600 strings in the shared components (modals, drawers, host cards, dictation,
accounts), the pull request sidebar and diff review to the translation API, with all
ten translations.

- Invalid account snapshots are detected by error type, not by matching English text
- Credit expiry wording uses separate keys for one and several credits instead of
  plural rules, which would pick the wrong sentence in Japanese or Russian
- The PR chip memo recomputes on a language change

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 18:17:58 -07:00
Kelvin Amoaba f87a746200 feat(mobile): light and dark themes with desktop's palettes (#34217)
* feat(mobile): light and dark themes with desktop's palettes

Settings → Appearance offers System, Light and Dark. The shell passes the
resolved scheme to over-the-air pages in its init frame.

Fixes #22184

* fix(mobile): draw your chat messages in desktop's user bubble

Desktop uses a faint surface with a hairline border in both themes; the inverted fill went black in light.

* test(mobile): re-measure the page script sweep after the shared theme hooks

Every route now imports the theme hooks while the entry does not, so they emit one shared script (96 for the tree). Re-swept every route prefix; the worst route step is now 15.

* test(mobile): read themed stylesheets in the text-input font-size census

The census only unwrapped StyleSheet.create({...}); inputs styled through themedStyleSheet(() => ({...})) came back unread.
2026-10-27 17:35:31 +00:00
Kelvin Amoaba c903d30283 fix(mobile): build the Source Control action sheet from desktop's menu rows (#34047)
* fix(mobile): build the Source Control action sheet from desktop's menu rows

Adds Force Push and desktop's order, enable rules and hints.

* fix(mobile): rebase from desktop's effective base and keep the Force Push warning in the action sheet

* fix(mobile): keep the rebase row's base when the branch compare fails

* fix(mobile): keep upstream compares working when the default base lookup fails

* fix(mobile): keep the rebase target apart from the branch compare so failed or unsupported compares keep it

* fix(mobile): leave force pushes to desktop when the linked review has its own push target

* refactor(mobile): rebase onto the shown base and share the force-push block with the primary action
2026-10-27 13:13:25 +00:00
Kelvin Amoaba 9d2e441947 fix(mobile): compare branches and show diffs with desktop's base-ref and whitespace settings (#34038)
* fix(mobile): compare branches and show diffs with desktop's base-ref and whitespace settings

Base-ref resolution moves to src/shared and the host publishes the compare-against-upstream
and show-whitespace settings, so the phone's branch compare and diffs match desktop.

* fix(mobile): use the upstream compare policy on the review screen and rebuild diffs when whitespace changes

* fix(mobile): reread Source Control settings on Refresh and restart diffs loading under the old whitespace setting

* test(mobile): cover diffs dropped when desktop's whitespace setting arrives

* refactor(mobile): share one Source Control settings read per host across screens
2026-10-27 05:29:46 +00:00
Kelvin Amoaba 6c2423d25d fix(mobile): list Source Control changes with desktop's sections, order and row rules (#34017)
* fix(mobile): list Source Control changes with desktop's sections, sort and row rules

Conflicts get their own section in the user's group order, submodule rows lose stage and
discard like desktop, paths sort with the shared collator, and review notes go out as desktop formats them.

* fix(mobile): read the group order the host now publishes and keep submodule rows read-only

The host's paired settings projection now carries sourceControlGroupOrder, the
normalized status projection keeps submodule metadata, and conflict rows reuse the
shared conflict-kind labels.

* refactor(mobile): share Source Control grouping and row rules with desktop

Desktop and mobile group rows through one shared helper, mobile's unstage button
follows desktop's rule, and the group order resets when the host changes.
2026-10-27 04:14:56 +00:00
Kelvin Amoaba 75743353de fix(mobile): prepare Create PR and the commit button with desktop's rules (#34011)
* fix(mobile): prepare Create PR and the commit action like desktop

Create PR now fast-forwards a behind-only branch before committing, picks its push step
from desktop's shared rule, and the commit-area button reads the branch's review state.

* fix(mobile): keep Create PR commit failures intact and skip fork push-target gating

Restaging after a failed commit is best effort and checks the branch first, and reviews
with their own push target are not gated until mobile reads targeted upstream status.

* fix(mobile): enter Create PR on desktop's intent rule and leave fork push targets ungated

* refactor(mobile): share the linked-review push decision and enter Create PR on desktop's intent rule

Desktop and mobile compose the review push target through one shared function, the
Create button admits branches Create PR can prepare, and older hosts without a lookup
outcome still fail closed on mobile.
2026-10-27 03:19:49 +00:00
Kelvin Amoaba 89e272abc5 fix(mobile): use desktop's merge gating and PR link rules in the PR panel (#33982)
* fix(mobile): gate PR merge and linking on desktop's shared rules

Merge state moves to src/shared so the phone stops offering Merge on drafts,
conflicts and blocked PRs, and stops letting branch PRs override explicit links.

* fix(mobile): keep stack merges and stale-link recovery in the PR panel

Mobile now applies desktop's stack override and drops a linked PR the branch has moved
past, and only offers Ready for review when a relaying desktop also forwards it.

* fix(mobile): persist stale PR link clears and add a merge recheck

The phone now writes desktop's link clear instead of rereading the stale link, and a
blocked Merge can be rechecked since nothing on mobile polls PR state.

* fix(mobile): guard stale PR link clears like desktop

Re-reads the worktree before clearing so a link chosen after the lookup survives, and
localizes the merge-state strings the lift exposed to the coverage gate.

* test(mobile): drop identity test for worktree link params

* refactor(mobile): share one stack-merge decision and trim PR panel wiring

* fix(mobile): default worktree bare/archived flags for the stale-link guard

* fix(mobile): label the PR merge button from its default merge method
2026-10-27 02:12:00 +00:00
OrcaWinandOrcaWin 7d6603aa17 [comment audit] Complete repository-wide comment cleanup (#33249)
* [comment audit] Align legacy failure-read counts with cached display

* [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci]

* [comment audit] Condense shared generation, path and preview comments [skip ci]

* [comment audit] Condense host, file and Git contract rationale [skip ci]

* [comment audit] Tighten Git lifecycle and provider contract comments [skip ci]

* [comment audit] Clarify shared host authority and launch isolation [skip ci]

* [comment audit] simplify local access mobile and native chat contracts [skip ci]

* [comment audit] simplify native chat presentation and state contracts [skip ci]

* [comment audit] simplify native runtime and provider contracts [skip ci]

* [comment audit] simplify migration and orchestration contracts [skip ci]

* [comment audit] simplify plugin trust and process capture rationale [skip ci]

* [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci]

* [comment audit] clarify remote recovery and launch wire rationale [skip ci]

* [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci]

* [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci]

* [comment audit] clarify sidebar, skill and SSH invariants [skip ci]

* [comment audit] condense structured chat delivery and timing rationale [skip ci]

* [comment audit] condense terminal replay and telemetry invariants [skip ci]

* [comment audit] clarify launch permission and terminal delivery rationale [skip ci]

* [comment audit] clarify workspace ownership and layout contracts [skip ci]

* [comment audit] wait for fitted and painted SSH terminal links

* [comment audit] clarify layout persistence and session ownership [skip ci]

* [comment audit] clarify host routing and workspace lifecycle contracts [skip ci]

* [comment audit] clarify startup persistence and app lifetime comments [skip ci]

* [comment audit] clarify recovery and activity boundaries [skip ci]

* [comment audit] condense activity and artifact invariants [skip ci]

* [comment audit] condense automation authority contracts [skip ci]

* [comment audit] simplify automation scheduling and history contracts [skip ci]

* [comment audit] clarify browser chrome and annotation lifecycles [skip ci]

* [comment audit] clarify browser routing and retained guest ownership [skip ci]

* [comment audit] clarify browser recovery and document ownership comments [skip ci]

* [comment audit] restore hosted runners required by native IME fixtures

* [comment audit] shorten preview, palette and dashboard invariants [skip ci]

* [comment audit] clarify terminal preview, dashboard and retention comments [skip ci]

* [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci]

* [comment audit] shorten rich-text and combined-diff ownership comments [skip ci]

* [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci]

* [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci]

* [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci]

* [comment audit] clarify rich Markdown source and selection safeguards [skip ci]

* [comment audit] clarify editor loading and emulator stream lifetimes [skip ci]

* [comment audit] trim emulator and feature tour narration [skip ci]

* [comment audit] clarify floating workspace and GitHub cache ownership [skip ci]

* [comment audit] trim provider controls and Linear filter explanations [skip ci]

* [comment audit] clarify pairing and chat resume lifetimes [skip ci]

* [comment audit] clarify chat delivery and transcript lifetimes [skip ci]

* [comment audit] clarify session options and host-owned status [skip ci]

* [comment audit] clarify draft durability and live-status precedence [skip ci]

* [comment audit] Trim chat send and model ownership explanations [skip ci]

* [comment audit] Shorten transcript ownership and send recovery comments [skip ci]

* [comment audit] Simplify native chat composer and transcript hook comments [skip ci]

* [comment audit] Shorten native chat scroll and structured session explanations [skip ci]

* [comment audit] Trim workspace picker and onboarding commentary [skip ci]

* [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci]

* [comment audit] Trim sidebar and session history explanations [skip ci]

* [comment audit] Condense session resume and review checks comments [skip ci]

* [comment audit] Shorten checks state and file explorer refresh comments [skip ci]

* [comment audit] Trim plugin bridge and source-control action rationale [skip ci]

* [comment audit] Trim source-control listing and review rationale [skip ci]

* [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci]

* [comment audit] Condense Settings account and appearance rationale [skip ci]

* [comment audit] Trim pairing and repository Settings explanations [skip ci]

* [comment audit] Condense Settings search SSH and terminal safeguards [skip ci]

* [comment audit] Shorten Settings voice and provider ownership comments [skip ci]

* [comment audit] Condense Settings draft persistence and host-state rationale [skip ci]

* [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci]

* [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci]

* [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci]

* [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci]

* [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci]

* [comment audit] Prune focus-read baseline after comment cleanup

* [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci]

* [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci]

* [comment audit] Clarify viewport geometry and skill update safeguards [skip ci]

* [comment audit] Tighten skill inventory, usage and host status comments [skip ci]

* [comment audit] Clarify usage ownership and resource kill safeguards [skip ci]

* [comment audit] Tighten tab selection, rename and search contracts [skip ci]

* [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci]

* [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci]

* [comment audit] Tighten provider loading and terminal recovery comments [skip ci]

* [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci]

* [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci]

* [comment audit] Tighten PTY startup and replay explanations [skip ci]

* [comment audit] Clarify PTY identity and delivery ownership [skip ci]

* [comment audit] Shorten remote reconnect and input replay rationale [skip ci]

* [comment audit] Condense terminal parking clipboard and file link comments [skip ci]

* [comment audit] Clarify IME ownership and trim trace history [skip ci]

* [comment audit] Shorten pane recovery and parked watcher explanations [skip ci]

* [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci]

* [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci]

* [comment audit] Trim renderer lifecycle and menu rationale [skip ci]

* [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci]

* [comment audit] Clarify renderer hook evidence and source ownership [skip ci]

* [comment audit] Tighten IPC ownership and reconnect explanations [skip ci]

* [comment audit] Trim renderer focus, paste and attention comments [skip ci]

* [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci]

* [comment audit] Trim agent launch, resume and ownership commentary [skip ci]

* [comment audit] Shorten account, browser and editor comments [skip ci]

* [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci]

* [comment audit] Condense launch recovery and editor tokenization comments [skip ci]

* [comment audit] Condense workspace selection and terminal pane rationale [skip ci]

* [comment audit] Condense terminal fitting and rendering recovery comments [skip ci]

* [comment audit] Condense terminal input and host routing explanations [skip ci]

* [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci]

* [comment audit] Condense structured launch and terminal link explanations [skip ci]

* [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci]

* [comment audit] Condense session persistence and workspace activation rationale [skip ci]

* [comment audit] Condense workspace routing and browser ownership rationale [skip ci]

* [comment audit] Condense mirror recovery and layout authority rationale [skip ci]

* [comment audit] Condense runtime transport and file safety rationale [skip ci]

* [comment audit] Condense runtime graph and structured session rationale [skip ci]

* [comment audit] Condense browser creation and remote tab recovery rationale [skip ci]

* [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci]

* [comment audit] Condense frame, startup and provider cache rationale [skip ci]

* [comment audit] Condense host twin and agent lifecycle rationale [skip ci]

* [comment audit] Condense browser authority and editor cleanup rationale [skip ci]

* [comment audit] Condense editor buffer identity and review cache rationale [skip ci]

* [comment audit] Condense provider lifetime and repository ownership rationale [skip ci]

* [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci]

* [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci]

* [comment audit] Condense cleanup ownership and listing admission rationale [skip ci]

* [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci]

* [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci]

* [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci]

* [comment audit] Condense cloud rollout and assignment safety rationale [skip ci]

* [comment audit] Condense relay session and database recovery rationale [skip ci]

* [comment audit] Condense relay placement and protocol compatibility rationale [skip ci]

* [comment audit] Condense build and packaging constraints [skip ci]

* [comment audit] Condense CI gates and validation isolation rationale [skip ci]

* [comment audit] Condense catalog identity and browser validation rationale [skip ci]

* [comment audit] Condense mobile browser and frame-budget rationale [skip ci]

* [comment audit] Shorten mobile route and browser test explanations [skip ci]

* [comment audit] Condense editor and session seam test rationale [skip ci]

* [comment audit] Trim session and terminal closure history [skip ci]

* [comment audit] Shorten terminal browser and census explanations [skip ci]

* [comment audit] Preserve root-only E2E diagnostic baseline

* [comment audit] Condense mobile seam and HTML preview test rationale [skip ci]

* [comment audit] Condense preview instrumentation and render harness rationale [skip ci]

* [comment audit] Condense bundle contracts and native-build constraints [skip ci]

* [comment audit] Shorten native packaging and CI routing rationale [skip ci]

* [comment audit] Condense release tooling and benchmark constraints [skip ci]

* [comment audit] Condense packaging and platform gate rationale [skip ci]

* [comment audit] Shorten mobile route and history rationale [skip ci]

* [comment audit] Condense mobile browser and component rationale [skip ci]

* [comment audit] Shorten drawer and workspace-form rationale [skip ci]

* [comment audit] Trim mobile review and editor-document explanations [skip ci]

* [comment audit] Condense editor lifecycle and Markdown explanations [skip ci]

* [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci]

* [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci]

* [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci]

* [comment audit] Condense bridge stream, native request and audio rationale [skip ci]

* [comment audit] Reduce bridge schema and page-client commentary [skip ci]

* [comment audit] Tighten mobile page bridge contracts [skip ci]

* [comment audit] Clarify mobile cache and session constraints [skip ci]

* [comment audit] Condense mobile route and lifecycle rationale [skip ci]

* [comment audit] Tighten mobile shell hook timing rationale [skip ci]

* [comment audit] Clarify route handoff and notification compatibility [skip ci]

* [comment audit] Clarify push registration and dictation cleanup constraints [skip ci]

* [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci]

* [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci]

* [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci]

* [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci]

* [comment audit] Tighten stream replay and queued session ownership rationale [skip ci]

* [comment audit] Condense mobile composer and reply-schema constraints [skip ci]

* [comment audit] Clarify mobile transcript recovery and session ownership [skip ci]

* [comment audit] Clarify mobile queue and source-control compatibility [skip ci]

* [comment audit] Condense storage and task launch constraints [skip ci]

* [comment audit] Clarify task schemas and terminal document ownership [skip ci]

* [comment audit] Tighten terminal input and viewport rationale [skip ci]

* [comment audit] Shorten terminal and RPC recorder contracts [skip ci]

* [comment audit] Condense recorder engine and connection contracts [skip ci]

* [comment audit] Shorten host credential and relay recovery comments [skip ci]

* [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci]

* [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci]

* [comment audit] Condense catalog and relay entry contracts [skip ci]

* [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci]

* [comment audit] Condense WSL relay and E2E regression rationale [skip ci]

* [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci]

* [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci]

* [comment audit] Shorten layout oracle and regression explanations [skip ci]

* [comment audit] Condense paired recovery and ownership regression contracts [skip ci]

* [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci]

* [comment audit] Condense terminal rendering and IME regression rationale [skip ci]

* [comment audit] Shorten terminal recovery and benchmark contracts [skip ci]

* [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci]

* [comment audit] Condense workflow routing and release rationale [skip ci]

* [comment audit] Condense mobile security and installer contracts [skip ci]

* [comment audit] Shorten native rationale and stylesheet workarounds [skip ci]

* [comment audit] Shorten Windows visibility probe rationale [skip ci]

* [comment audit] Condense embedded release and signing rationale [skip ci]

* [comment audit] Retain current main test behavior in final reconciliation [skip ci]

* [comment audit] Complete final comment review across current source inventory

* [comment audit] Handle consolidated audit reports before final CI

* [comment audit] Preserve credential storage census detail [skip ci]

* [comment audit] Scale broad changed E2E selections with verified timing shards

* [comment audit] Align E2E assertions with browser close and settled session metadata

* [comment audit] Match existing E2Es to current resource and windowing contracts

* [comment audit] Observe the shared server outage notice in reconnect coverage

* [comment audit] Make existing renderer probes measure settled tab and PTY work

* [comment audit] Align palette E2E selector with task search

* [comment audit] Refresh remaining palette search selectors

* [comment audit] Keep selector correction within selected audit specs

* [comment audit] Await emoji fixture settings inside renderer evaluation

* [comment audit] Cancel fixture fit retries between terminal tests

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-25 04:05:46 -07:00
OrcaWinandOrcaWin fdcf887bf0 chore(lint): fix suppressed lint violations instead of ignoring them (#33294)
* chore(lint): remove disable directives that suppress nothing

Each was reported unused by the oxlint config that owns its rule (root,
casting, react-doctor, anti-slop, native-plugins), including 17 file-level
react-doctor blanket disables whose rule no longer fires. An unused
react-hooks disable was masking a react/purity finding in
useAgentBucketCounts (compiler rules skip functions with hook
suppressions); suppress that one explicitly where the code explains it.

* test(tab-bar): use typed tab/file fixtures instead of @ts-expect-error partials

* chore(lint): fix misc suppressed lint violations instead of disabling

- consistent-type-imports: the "vi.importActual requires inline import()"
  rationale was wrong; use typed vi.mock(import(...), importOriginal) or a
  type-only namespace import.
- no-control-regex: source-control commit/push failure summaries, shell PATH
  hydration, and ipynb ANSI parsing reuse the shared ansi-escape-sequences
  stripper and control-character pattern instead of private copies.
- no-explicit-any / no-useless-spread / no-this-alias: typed `this` in
  bridge test mocks, drop blanket file-level disables, drop a this-alias.

* refactor(main): replace avoidable type assertions with checked narrowing

17 production cast suppressions in main/wsl-guest removed: untrusted IPC
and RPC payloads (Jira issue update, WSL guest plugin install, handshake
type) now narrow through isRecord/type guards and build typed results;
redundant casts dropped; hand-rolled object checks and errno casts use the
existing isRecord / hasErrorCode helpers.

* refactor(renderer,mobile): replace avoidable type assertions with checked types

13 production cast suppressions removed: RPC failures read through the
`ok` discriminant instead of casting to RpcFailure; react-native-web host
refs narrowed with instanceof HTMLElement; redundant reply/diff casts
become checked annotations; the TUI agent param uses z.custom(isTuiAgent)
like its sibling schemas; the agent-status slice cast that hid missing
actions is gone so the creator's return type checks the whole slice.

* chore(lint): drop dead tiptap wrapper; scope anti-slop mocking rule to test support

- safe-react-node-view-renderer patched handleSelectionUpdate, which
  @tiptap/react 3.31.3 no longer has, so it always returned early. Use the
  stock ReactNodeViewRenderer.
- The anti-slop test override only matched *.test.{ts,tsx}, forcing
  file-level no-module-mocking disables in test harnesses and fixtures.
  Match *-test-harness, *-test-fixture(s), .test-fixture and *.test.mjs,
  then drop those disables and the now-redundant per-file exemptions.

* test: replace repeated cast suppressions with typed fixtures (main/shared/e2e)

Net -119 cast directives. Typed fixtures replace recurring casts:
unusedSshConnection/unusedOrcadHostTransport placeholders, a typed
readPersistedState harness read (exposed a test treating optional
httpProxyUrl as always set), real Response objects in fetch spies, an
updater main-window fixture, a CDP webContents adapter, one shared
releaseModuleExport for cross-version tests, and requireOverlayDir in
place of `as string` on possibly-undefined overlay paths.

* fix(runtime): validate loaded device rows and host scope instead of casting

Two cast suppressions claimed checks that did not happen:
- device-registry-file-read: "normalizeLoadedDeviceRow re-validates every
  field" was false; deviceId, token, name and timestamps were never
  checked. Rows without an identity are now dropped and the file is set
  aside through the existing corrupt-file path (registry reads as
  doubtful, so no revocation verdicts); a non-string token is stripped
  so the row stays listed but cannot authenticate.
- client-ui: "only optionality differs" was false; any string reached
  persisted workspaceHostScope. Narrow it with isWorkspaceHostScope and
  ignore unknown scopes (wire schema unchanged for mixed versions). The
  remaining cast now states what it actually covers.

* test(runtime,ipc): drop redundant cast suppressions; typed runtime store fixture

-98 cast directives across runtime/ipc tests: 61 casts that typecheck
without the assertion; a typed vi.fn for scoped invalidation; a
partialRuntimeStore fixture in place of `store as never` for
OrcaRuntimeService; typed worker terminal/authority builders in the
incarnation-fallback suite (the old stub lacked runtimeId, ptyId,
worktreeId and launchTokenHash) plus removal of stacked duplicate
directives there.

* chore(lint): typed vi.mock imports and explicit listener snapshot in shared-bucket tests

Remaining files from the misc-rule pass that overlapped other buckets:
codex-accounts mocks use the typed vi.mock(import(...)) form instead of
a consistent-type-imports disable; the daemon late-exit fixture copies
its listener set with Array.from instead of a no-useless-spread disable.

* refactor(github,mobile): narrow routing settings type; drop remaining avoidable casts

- work-item-routing cast a one-field object to AppState['settings'] under
  a claim that readers only consume activeRuntimeEnvironmentId. Return the
  existing RuntimeFocusSettings pick instead and narrow the cache-key
  chain's parameters, so the compiler enforces that claim.
- mobile file preview reads refusals through the RpcResponse `ok`
  discriminant rather than a cast.
- terminal document host-notify accepts `unknown` errors, removing the
  message-bridge cast.
- native chat readability drops a redundant repo-list cast.

* test(renderer): replace repeated cast suppressions with typed fixtures

-140 cast directives (and ~150 unsuppressed casts of the same shapes):
- mockPanePtyArgs owns the connectPanePty fixture casts, so a wrong
  argument order or fixture is now a type error; drop binding casts for
  members PanePtyBinding already declares.
- Seed AppState tab rows with the typed makeTerminalTab; this exposed a
  split direction 'row' that is not a TerminalPaneSplitDirection.
- Partial GlobalSettings use getDefaultSettings overrides; Jira-only
  stores use createTestStore; runtime environment rows/statuses come from
  a typed paired-runtime-environment fixture.

* chore(lint): drop no-module-mocking disable now covered by the test-fixture glob

* test(e2e): fix all 221 grandfathered e2e type errors; baseline to zero

The e2e typecheck baseline grandfathered 221 errors in 119 files. About a
third were tests drifted from the current API, several of which would
fail or mislead at runtime:
- terminal-shortcuts called a nonexistent xterm buffer method;
- the daemon-lifecycle fake lacked terminateOwnedTree, hidden by importing
  its type from a module that does not export it;
- arguments in the wrong position (listProcesses(hostId, opts), a dropped
  pty.write inputKind, an extra toWebTerminalSurfaceTabId argument);
- removed enum values ('drawer', 'idle', 'editor',
  'session-insecure-origin') and a launch_source the telemetry schema
  rejects;
- missing required fields (turnScope, childWorkOpen, providerIdentity,
  reviewLookupOutcome, runtimeServiceAccount behind an `as RelayConfig`);
- dead reads (tab.customTitle, bucket.targets, changedPixels).
Also lifts variables that narrowed to `never` after poll-callback
assignment, and adds a typed xterm render-dimensions accessor. Includes
the cross-version suites' shared releaseModuleExport adoption.

* chore(typecheck): gate the now-clean e2e typecheck in local lint

The e2e baseline is empty, so the ratchet now rejects any tests/ type
error. CI already runs it via run-ratchets; add it to pnpm lint's
explicit ratchet list and replace the stale 'not yet clean' note.

* test(mobile): typecheck every mobile test; baseline 105 files -> 0

mobile/tests-typecheck-baseline.txt grandfathered 105 test files (531
errors) whose types CI never checked. Contract changes had landed without
these tests following, and type pins in them silently checked nothing.

- Root cause: salvagedOptional (src/shared/zod-salvage.ts) cast away
  zod's optional marker, so every reply type built on it claimed
  salvaged fields were always present. Return a real ZodOptional;
  runtime is unchanged (the outer optional short-circuits undefined).
- Production now takes the port it uses: RpcOperationSender instead of
  RpcClient for send-only callers, a narrow fetch signature, and only the
  request fields push-receive reads.
- findAllByHostType/findByHostType replace `'Text' as never` lookups;
  stubRpcClient gives tests a typed RpcClient.
- Real test bugs: settings-host-client-lifecycle "relay" hosts were
  direct hosts (relayHostId no longer exists); two toEqualTypeOf pins
  could never hold; a recovery hook ran with an undefined scope key;
  a create-PR test cast to a never-imported type; "no URL" assertions
  passed on null; plus stale required props, args and enum values.

* test(e2e): parse workerStart result instead of casting it

* test(runtime): typed partial runtime stores with production-shaped fixture data

Route every partial store passed to OrcaRuntimeService through
partialRuntimeStore with typed row builders, removing 60 cast directives
that hid fixture data production never produces:
- paired-client-navigation-test-harness keyed setWorktreeMeta writes by
  `[object Object]` (it took an id where the store takes a target), so
  reads never saw them;
- invalid worktree lineage origin/capture and incomplete workspace lineage;
- getWorkspaceSession returning null where the store returns the default
  session; getGitHubCache returning undefined;
- orcad migration states missing migrationId/manifest/receipt, with a
  toEqual pinned to that incomplete shape;
- partial repos, metadata, folders, project groups and notification rows.

* refactor(renderer): resolve suppressed React hook lint instead of disabling it

109 of 187 react-hooks/react-doctor suppressions fixed:
- ~95 exhaustive-deps disables only hid stable refs and setters passed
  through controller objects; list them (identity never changes, so
  nothing re-runs), plus a few real stale closures (tabId/worktreeId).
- Effects keyed on one value that read the latest props now use
  useEffectEvent (WebConnect, skill freshness, Bitbucket dialog seeding,
  Landing preflight, automation catalog, structured-session recovery,
  file explorer).
- The conditional layout-owner hook is selected once at module load from
  the build constant instead of a rules-of-hooks disable that also hid
  the whole controller from compiler and react-doctor rules.
- A hook disable was masking a react/purity Date.now() in the palette's
  recent-tabs memo; sample it when the snapshot is captured.
- Three identical debounced-search effects share
  useTaskSearchDebouncedApply; layout snapshot merge helpers move out to
  stay under max-lines.
Remaining suppressions are mount-once resources, invalidation epochs,
deliberately keyed fields, and test harnesses that mock the dispatcher.

* chore(ratchet): exempt test harnesses and fixtures from the focus-read ratchet

The ratchet skipped *.test-fixture.ts but scanned *-test-harness,
*-test-fixture(s) and *.test-support files. Those seed settings with
getDefaultSettings (which the ratchet discovers as a focus reader) and
never route host work, so 17 baseline entries were false positives, and
replacing a test's `settings: {...} as never` with typed defaults tripped
it. Match the repo's test-support names and prune (which also drops one
stale entry), and rename the frame-ordering harness to *-test-harness like
its siblings. A blanket "-harness" match would also exempt
e2e-terminal-pty-harness, which production imports, so the names stay
explicit.

* test: merge duplicate shared/constants imports

* revert(file-explorer): restore original tree load effects pending SSH e2e investigation

* test(runtime): drop RuntimeStore cast now that the fixture is typed

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-24 19:37:19 -07:00
OrcaWinandOrcaWin 886ac45c67 test: remove duplicate checks and repair weak fixtures (#33353)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-24 19:14:30 -07:00
OrcaWinandOrcaWin 8eea0c0d5b test: remove redundant cases and repair audit assertions (#33334)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-24 18:26:35 -07:00
Jinjing 8aa7f9c047 Combine untracked files into Changes (#31831)
* Combine untracked files into the Changes section

* Localize discard warnings
2026-10-21 22:23:59 -07:00
Neil 3cca64bef6 fix(mobile): keep a closed branch preview from reopening when loading finishes (#31707)
* fix(mobile): retire dismissed branch diff requests

A pending branch diff reopens a dismissed drawer and blocks replacement previews. Retire requests on dismissal and host/workspace changes, and keep spinner ownership with the current request.

* fix(mobile): retain branch preview through suspended transitions

* test(mobile): retain confirmed branch previews across connection cutover
2026-10-21 21:20:56 -07:00
Neil 250f81ae9f fix(mobile): own commit generation by workspace and admit once (#31709)
* fix(mobile): own commit generation by workspace and admit once

AI commit generation crosses workspace transitions and admits duplicate calls before a render. Retire the active run on scope changes and use synchronous admission.

* fix(mobile): retire commit generations only after scope commits
2026-10-21 17:24:58 -07:00
Neil a3c9958973 fix(mobile): show commit comparison failures with retry (#31706)
* fix(mobile): show commit comparison failures with retry

Comparison refusals, transport errors and malformed payloads appear as No file changes. Decode accepted data, preserve loaded files on failed refresh, and offer Retry.

* test(mobile): record commit comparison errors and retry affordance

* refactor(browser): simplify address bar callbacks within lint limit

* style(browser): keep client-hosted pane within line limit

* fix(i18n): retire obsolete Portuguese default zoom entries
2026-10-21 17:21:16 -07:00
Neil 4f70d6cee5 fix(mobile): refresh staged files when returning from review (#31295)
Refresh Source Control when returning from review so staged files and commit availability reflect completed mutations.

Validation: focused loader tests, iPhone simulator proof, and all CI checks passed.
2026-10-20 19:31:01 -07:00
Kelvin Amoaba b0a7d2eb48 fix(mobile): keep typed commit messages, restore failed shortcut removals, name icon buttons (#30142) 2026-10-18 20:05:28 +00:00
Neil aa07d5891a fix(mobile): break the transport and terminal-document import cycles, and gate cycles after mobile's install (#30290)
* fix(mobile): break the transport and terminal-document import cycles and gate them after mobile's install

client-context re-exported the host-client hooks, which import client-context back: the re-export
barrel is gone and importers (and their test mocks) name host-client-hooks directly. In the terminal
document, the cell-metric getters move to a leaf cell-metrics module and select-mode entry/exit to
selection-mode, so fit, transform, overlay and range no longer import each other. pr.yml lints imports
before mobile's install, when mobile's tsconfig can't load, so Mobile verify now runs the same import
rules over mobile after its install with zero warnings allowed.

* fix(mobile): the browser-pane render harness imports useHostClient from host-client-hooks

Its route source is a template string, so no typecheck saw the removed client-context re-export; the mobile web bundle check did.

* fix(ci): the e2e typecheck ratchet counts the same with or without mobile's install

Specs reach mobile/src/transport, whose expo-crypto and async-storage imports resolve only after mobile's install and then load React Native's globals (numeric timers, a required NODE_ENV), adding errors in unrelated specs. An empty stub pins both, so the static job reads 232 either way.
2026-10-18 04:00:24 -07:00
Neilandzhangxaochen 720f45c8f7 Fix mobile Source Control rendering for large branches
Render committed branch changes as virtualized SectionList rows for clean and dirty working trees, keeping all files accessible without delaying Back. Share the existing sleek scrollbar rules with mobile web pages and retain explicit hidden-indicator settings.

Fixes #30170. Review reference: #30171; thanks to @zhangxaochen for the diagnosis and initial proposed fix.

Co-authored-by: zhangxaochen <zhangxaochen@163.com>
2026-10-18 00:20:23 -07:00
Jinwoo HongandClaude cd1ff506e1 feat(mobile): list the desktop's server projects on the phone's Tasks screen (#27741)
* test(e2e): add a paired phone client helper and phone-mirror baseline specs

Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the
phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve
restart and today's gap: a desktop's phone does not list the desktop's server workspaces.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): show SSH host health on the workspace row host badge

Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): compose the phone-mirror topology and check the cold snapshot

Slices need one composer for host, desktop and phone with a single teardown, so specs stop
copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold
start, which is what the phone builds its tab strip from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name an unhealthy SSH host even in a single-host list

The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): keep phone-mirror teardown errors from masking test failures

Teardown failures become a test attachment instead of replacing the real failure, and the
cold-snapshot poll reports stream errors together with the PTY list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): clear stale SSH host badges and announce them without the dot

A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(runtime): let a paired desktop register its phones as delegated mobile devices

A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of
its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the
desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(runtime): move the mobile device revoke primitive below dispatch

Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in
a layer under it as a pure move; this drops the abstract method seam. No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): keep a runtime grant until its delegated phones are revoked

A failed child cleanup now leaves the parent in place so a retry can still find the children, and
revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage,
shares credential minting, and drops the timing-dependent census.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): fail a delegated sync whose dropped phone cannot be revoked

Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors
before minting anything, so the desktop retries instead of believing the server matches its list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): relay a paired phone's server-workspace requests through the desktop

A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop
now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per
(phone, server), signed in as the phone's delegated device; replies pass through untouched except
stream ids. Hidden until the phone client ships: the relay capability is not advertised yet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the single-host health fixtures inline so the test tsc passes

Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the desktop relay contract in its entry module

main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a
server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a
later request syncs it again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the relay's configured-server adapter

The production hosts adapter was only typechecked; this pins that it resolves configured servers
by exact id only (never a name or a removed server), fences by pairing revision and runtime
identity, and stops reporting retirements once unsubscribed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the repo-metadata goldens for SSH host health

The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): tighten the desktop relay after elegance review

Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request
whose link ended while opening, passes failure codes through, relays only this desktop's own
phones, and drops the unused host-state reader until S2 needs one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound relay frames and stop sticky relay verdicts

Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on
each new link so an in-place upgrade relays, answers a phone the server just declined without
syncing again, and ends a link whose mirrored capability update cannot be queued.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run targeted desktop-owned calls on the desktop

The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the desktop's configured servers and their last workspace rows for the phone

The phone needs the same host list the desktop shows, including servers that are offline or
too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and
its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): list server rows through one desktop fetch, with no side effect on listing

The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the
desktop's own fetch is both the live source and the cache, and stamping stays in main.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers

The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows
when a server hangs, drops a server's rows when it is removed or re-paired, and checks the
server's identity after the fetch so a reply from before a re-pair is never served.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name a server workspace's host on the phone's calls

A phone reaches a server's workspace through its desktop only if each call names the host; the
direct, cloud-relay and shell-bridge transports now carry `executionHost`, streams keep it for
replay and unsubscribe, and an older shell (no `executionHost` grant) refuses rather than strips it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete

A server Orca manages on an SSH host now lists once under that host's name, or not at all while
the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is
deleting are left out at reply time, as a phone asking the server itself would see.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch

Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied
the phone's list; rows are now pruned only once a server is no longer configured. Fetches are
shared per server identity, so a poll after a re-pair asks the new server right away.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the paired desktop's server workspaces on the phone

Fixes the phone missing a desktop's `orca serve` workspaces: rows come from the desktop's
mobileRelay.hosts.* with each server's health ("Update needed", "Disconnected"), open through the
relay, follow the desktop's hidden hosts, and the desktop now advertises the relay capability.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a desktop failing the hosts list leaves only its own rows

The page asks a relaying desktop for its servers; a refusal such as method_not_found must leave
today's list with no server rows and no catalog error.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): mirror the desktop's shown hosts exactly, one host included

Jinwoo's call: the phone follows visibleWorkspaceHostIds as the desktop sidebar does, so a
one-host list narrows the phone too instead of being read as a single-host focus.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): scope a server workspace at its route, never the desktop

Every screen under a server workspace's route now reads one context and
gets that server's client and a router that keeps pushes on it, refusing
to fall back to the desktop; pins write to the row's owner like the Mac.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never open an unreachable server workspace, and prove the relay in e2e

A server workspace the phone can't reach now gets one explicit screen at its route
and no row tap or action-sheet entry opens it; native routes read the route's host,
a census pins every workspace route, and the e2e reads files and git on the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): gate a server workspace on that server's own features

A targeted status.get now relays to the server, so a server workspace's route
provides that server's status to every screen under it and its action sheet,
while whether the phone can reach the server stays the desktop's answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(ci): run the remote-runtime WebSocket opener test on Node

Under the bun vitest project, `ws` resolves to Bun's built-in client, which
ignores maxPayload, so the frame-cap test hung. The opener only ships on
Node/Electron, so run its test in the Node runtime project like its siblings.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run a server workspace's SSH calls on that server

A phone in a server workspace asks for SSH state before a file mutation, but the Mac answered
about its own targets, so server-owned SSH repos failed. The desktop asks the server, so the
ssh.* family now routes by target; an e2e pins files, uploads, commits and review on the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the server files/git e2e offline and baseline-git safe

Review eligibility reached real gh/glab over the network and proved nothing beyond repoSlug;
setup git now ignores the runner's config and avoids `init -b` (Git 2.25). The relay
integration test is the routing oracle, so the census re-listing goes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): sleep, wake and new workspaces on a desktop's servers from the phone

The desktop renderer alone sleeps a server's workspace and holds its slept agents, so the phone
now asks the desktop (two paired-desktop methods) instead of the headless server; the
new-workspace sheet lists and creates on reachable servers the way the desktop's composer does.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): sleep and wake only a server the desktop has configured

A phone's sleep or wake names a server by host id; the desktop now refuses one that is not in
its own configured servers, read from its settings rather than any fetched workspace list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep server workspaces' repos, picks and sleeps on their own host

A hung server no longer holds back the desktop's repos, a repo id shared by two hosts no longer
swaps the pick, and a server row's optimistic sleep waits for the server's own list to confirm it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the server-workspaces test rows every Worktree field

The tests-typecheck ratchet checks mobile test files; the fixture row was missing required
Worktree members, so the file no longer typechecked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the file preview the workspace router, back to 128 page assets

The preview was the one workspace screen on the plain router, so the scoped router
was reached by a route set of its own and esbuild split it into an extra chunk
(129 assets); every workspace screen now uses it and it shares the route's chunk.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* revert(mobile): drop server sleep and wake from the phone for now

The workspace-layout refactor deletes the notifier path these used; sleep and
wake on a server come back later as server-side layout.sleep / layout.wake.
A server row's Sleep behaves exactly as on feat/mobile-remote-workspaces.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): derive the phone allowlist from the routing table

Adding a phone method now takes one edit (its route). A server-targeted call on a
legacy non-E2EE socket is refused instead of running locally, and only
execution-host methods enter the relay.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): drop unread freshness fields from server workspace lists

The phone reads only the rows, so stale, fetchedAt, totalCount and truncated
were dead wire weight. The desktop still serves a server's last rows while
the server is unreachable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read only the rows of a server's workspace list

The desktop no longer sends a freshness flag, so the phone's schema drops it.
The relay capability list names the delegated-devices constant instead of
repeating its literal, which also retires a now-circular type check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): open server-workspace diffs and file taps on the phone's own screens

A headless orca serve has no renderer, so files.openDiff / files.open answer
renderer_unavailable and the Changes tap showed that raw code. On that refusal the
phone now opens its review screen (git.diff) or file preview (files.read) instead.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show a server workspace as disconnected while its server is down

The phone read the desktop's connection state for a server workspace, so a stopped
server kept a green dot and "Loading tabs" spun forever. The workspace route now
narrows that state by the server's health from mobileRelay.hosts.list, reusing the
existing disconnected session UI and refetching tabs when the server returns.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name run targets and the desktop's own row as the host list does

The Run on picker showed a server's raw environment id behind "Remote ·" and
always a monitor glyph; it now takes the host list's badge labels and icon, as
the desktop's picker does. Server rows also made the desktop row read "This
computer" because its platform was only read for a mixed desktop catalog.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop Open in Session and preview new notes in server workspaces

A server workspace's host has no editor tabs, so the review screen no longer offers
Open in Session there, and a new markdown note whose files.open answers
renderer_unavailable opens in the file preview instead of a raw error toast.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): share one desktop server-list feed between the host list and workspaces

A server workspace polled mobileRelay.hosts.list beside the host list's own read, so
the phone could ask the desktop twice per tick and the row badge and the screen could
disagree. One feed per desktop client now owns that read; both readers join it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): read a server's reachability from the hosts list, not a dropped stale flag

eff410fe30 dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's
schema never parsed and its first poll timed out. It now waits on the server's
relay/health from mobileRelay.hosts.list, the field the phone itself reads.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): re-measure the page's script sweep for server workspaces

Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace
route's shared modules split importer sets three ways more, reaching 81. Re-swept per
route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): mock react-native where the server-list feed now loads it

The merged server-list feed imports react-native's AppState, which vitest cannot parse unmocked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): offer the desktop's servers in a new workspace opened before the list loads

The sheet's run targets came from the host list's server rows, which never load while
the sheet is open (the list deliberately pauses its refresh behind it). From Home's
New Workspace that left only the desktop's repos. The sheet now reads the desktop's
server-list feed itself while it is open.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the desktop's servers in a new workspace opened before the list loads

The previous fix offered the servers but still named them from the host list's rows,
so a sheet opened from Home showed a server's raw id under Run on. The sheet now takes
both the servers and their names from the server-list feed while it is open.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): export the server-list feed's React reader beside the feed

useRelayHosts moves from workspace-server.ts into relay-hosts-feed.ts, so every reader (workspace screens now, the new-workspace sheet next) subscribes through the feed's own module. No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the server run-target fixture's host id as a runtime id

A plain string executionHostId is not an ExecutionHostId, so the mobile tests typecheck ratchet refused the file.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): stop requiring the dropped stale flag in the files and git review spec

eff410fe30 removed stale from mobileRelay.hosts.worktrees, so the schema never parsed and the row poll at :100 timed out.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): offer a server's SSH repos on the New Workspace sheet

The desktop's composer offers a paired server's own SSH repos and connects them through that
server. The phone left them out only because ssh.* was the Mac's own; it now reaches the server
(#26519), so the drawer's existing SSH gate connects through the picked repo's server client.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hide New Note where the host opens no editor tabs; drop stale pushes

A server workspace's host has no desktop window, so New Note left an unopenable
untitled.md each tap. It now hides under the same route rule as Open in Session, and
the no-window fallbacks no longer push a screen after the user has moved on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): let the new-workspace sheet read its servers where it knows it is open

The sheet's controller already owns whether it is open, so it now reads the desktop's
server list and names itself; the overlay's mirrored visibility state and the list-side
hook go away. A server row no longer offers Sleep, which would sleep it on the desktop.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): key the New Workspace SSH gate by the host holding the target

The desktop and a server can each have an SSH target with the same id. The gate's state, Connect
progress and detected agents were keyed by that id alone, so switching to the server's target showed
the desktop's "connected" until the server answered, and let create through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name the editor-tab gate for what it measures

useWorkspaceOnPairedHost says the workspace runs on the paired desktop itself; its doc
states the rule (hide actions whose only result is an editor tab) and its known gap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep polling the server list and read a server's reachability from relay

A hosts-list read slower than the poll stopped the feed for good, since the next tick was
scheduled when the read started; it is now scheduled from the finish. A server workspace now
reads connected only when the desktop relays to it, so a server with no status yet is not shown up.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the desktop's server repos on the phone's Tasks screen

The desktop's task page lists every host's repos and sends each repo's work-item calls to the host
that owns it. The phone listed only the desktop's own repos; it now adds each reachable server's
repos and runs every call naming one of them on that server through the desktop.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): offer only the repos the desktop's New Workspace offers

The sheet now builds its lists from the desktop's own eligibility rule, so a server's
per-workspace-environment SSH repo never appears or wins a server's row. A connect reply that
arrives after the picked target changed no longer overwrites the newly shown target's state.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show a reached server as connecting and a too-old one as disconnected

A server the desktop has reached but whose status is not answered yet is moments from relaying,
so it reads connecting; a server too old to relay to never will, so it reads disconnected.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): export the New Workspace SSH target key

Names an SSH target by its holding host and id, so other server-aware screens can key their SSH
state the same way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let Tasks show the desktop's repos before a server answers

The Tasks list waited for every server's repo list, so one slow server held up the desktop's own.
The desktop list now settles alone and each server's joins as it answers, through the same hook
the new-workspace sheet uses. Server repos name their host in the repo pickers, and agent detection
takes the router's per-repo resolver.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the New Workspace sheet on a hidden repo's project

A last-visited per-workspace-environment repo is hidden, so the sheet fell back to the first
listed project. It now maps it to its project's eligible copy with the desktop's resolver, moved to
src/shared. The phone's mirror of the seeding rule is gone, since the hook imports the shared one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run a server SSH repo's Tasks checks on that server

F2 lists a server's SSH-backed repos, so Tasks now reads and connects their SSH targets through
the repo's own client and keys the drawer's SSH state by (host, connectionId): the desktop and a
server can each hold a target with the same id. Tests drive the real GitHub list loader.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a Tasks repo filter and the listed items while servers join

A saved selection naming a server's repo waits until every listed server has answered or failed,
instead of widening to all repos. A server's repos joining reload the list quietly, so the
desktop's items stay up, and a mixed list names the desktop's own host in the pickers too.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): apply a Tasks repo filter early and keep it through server blips

A saved filter applies once every saved id is listed or every server has answered, so a
desktop-only filter never waits on servers, and a server that drops out briefly keeps its ids.
One shared hook gives both repo pickers their server clients, keyed on the ready set, and an
unchanged server answer no longer reloads the list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): derive the Tasks repo filter instead of pruning it

The saved selection is now the user's choice, written only by them and never pruned; every
reader narrows it to what is listed now, so a late, flapping or reconnecting server keeps its
repos selected and a removed one reads as all. This deletes the reconcile hook, its refs and
the server-settled tracking.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): edit the Tasks repo filter from what's shown

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): load the Tasks list once per server repo change

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(shared): move the account capabilities into their own group module

Main's merge put protocol-version.ts at 301 lines, over the 300 cap. The account group moves out
like the relay and skill groups did, same strings in the same order; the page's script sweep is
re-measured because the split changed its chunking (81 to 79 at 16 routes).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the Tasks mock's exports from a typed map

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): use type imports and full deps in the Tasks server-repo code

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name a server workspace through the request envelope

The server name now rides the generic request envelope from #27815 instead of
its own executionHost option, bridge grant and per-transport plumbing. Server
rows show only where the transport carries an envelope (native, or a shell
that grants rpcEnvelope), so an older app keeps today's list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the server-call wire and the no-envelope deep link

A server workspace call still reaches the desktop as the same top-level
executionHost frame, on the direct and relay wires, and a deep link into a
server workspace under an app without rpcEnvelope shows the unreachable screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read a relay stream's envelope slot in sorted key order

Equal envelopes name one cleanup slot whatever their key order, the scoped view
passes its client's envelope answer through as it does the generation, and the
direct wire harness is shared instead of rebuilt.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): a phone's new chat in a server workspace follows the desktop's chat setting

A relayed agent.launch ran on the server, which picked chat vs terminal from its
own setting. The desktop relay now stamps its own default as nativeChatDefault;
new servers use it in place of their setting (feasibility still decides), older
servers strip it. Outside the replay fingerprint.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(mobile): move the relay's delegated phone grant cache into its own module

Keeps mobile-desktop-relay.ts under the line limit once #27749's page relay lands on it.

Co-Authored-By: Claude <noreply@anthropic.com>

* revert(mobile): stop offering a server's SSH repos on the phone

Since #27609 a server cannot serve SSH repos at all (repo.addRemote throws and SSH-routed work on
a server fails), so the phone should not offer them. This reverts #27728's own commits
(c4c058ad15, 4a5ccaa9a3, 3b414b8842, a2bb1cf8a5, adb8ac5825) and the server-SSH parts of
ce4b125b40: the New Workspace sheet and the Tasks screen again leave a server's SSH-backed repos
out, and Tasks' SSH checks run on the desktop with its SSH state keyed by connection id only.
Affected goldens re-recorded with `pnpm --dir mobile rpc:record`.

Kept from ce4b125b40: useHostProtocolGates in host-protocol-gates-context (later commits import
it there), the Tasks route requiring the gate, and the typed ListedRepo (the SSH filter reads it).
Kept from #27728: nothing; the SSH filter now lives in the shared listServerRepos both screens use.
The Tasks filter-edit test lists a third server repo so picking two stays a filter.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show the early terminal tab from the relaying desktop's chat default

The early-tab preflight read the server's own chat setting while the launch
used the desktop's stamped nativeChatDefault, so on a windowed server with
chats off a relayed chat launch flashed a terminal tab. Both now read the
settings through readAgentLaunchModeSettings(runtime, nativeChatDefault).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 01:55:55 -04:00
Jinwoo HongandClaude 75513668fb fix(mobile): check a server workspace's SSH state on that server (#26519)
* test(e2e): add a paired phone client helper and phone-mirror baseline specs

Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the
phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve
restart and today's gap: a desktop's phone does not list the desktop's server workspaces.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): show SSH host health on the workspace row host badge

Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): compose the phone-mirror topology and check the cold snapshot

Slices need one composer for host, desktop and phone with a single teardown, so specs stop
copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold
start, which is what the phone builds its tab strip from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name an unhealthy SSH host even in a single-host list

The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): keep phone-mirror teardown errors from masking test failures

Teardown failures become a test attachment instead of replacing the real failure, and the
cold-snapshot poll reports stream errors together with the PTY list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): clear stale SSH host badges and announce them without the dot

A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(runtime): let a paired desktop register its phones as delegated mobile devices

A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of
its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the
desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(runtime): move the mobile device revoke primitive below dispatch

Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in
a layer under it as a pure move; this drops the abstract method seam. No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): keep a runtime grant until its delegated phones are revoked

A failed child cleanup now leaves the parent in place so a retry can still find the children, and
revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage,
shares credential minting, and drops the timing-dependent census.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): fail a delegated sync whose dropped phone cannot be revoked

Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors
before minting anything, so the desktop retries instead of believing the server matches its list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): relay a paired phone's server-workspace requests through the desktop

A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop
now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per
(phone, server), signed in as the phone's delegated device; replies pass through untouched except
stream ids. Hidden until the phone client ships: the relay capability is not advertised yet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the single-host health fixtures inline so the test tsc passes

Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the desktop relay contract in its entry module

main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a
server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a
later request syncs it again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the relay's configured-server adapter

The production hosts adapter was only typechecked; this pins that it resolves configured servers
by exact id only (never a name or a removed server), fences by pairing revision and runtime
identity, and stops reporting retirements once unsubscribed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the repo-metadata goldens for SSH host health

The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): tighten the desktop relay after elegance review

Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request
whose link ended while opening, passes failure codes through, relays only this desktop's own
phones, and drops the unused host-state reader until S2 needs one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound relay frames and stop sticky relay verdicts

Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on
each new link so an in-place upgrade relays, answers a phone the server just declined without
syncing again, and ends a link whose mirrored capability update cannot be queued.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run targeted desktop-owned calls on the desktop

The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the desktop's configured servers and their last workspace rows for the phone

The phone needs the same host list the desktop shows, including servers that are offline or
too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and
its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): list server rows through one desktop fetch, with no side effect on listing

The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the
desktop's own fetch is both the live source and the cache, and stamping stays in main.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers

The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows
when a server hangs, drops a server's rows when it is removed or re-paired, and checks the
server's identity after the fetch so a reply from before a re-pair is never served.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name a server workspace's host on the phone's calls

A phone reaches a server's workspace through its desktop only if each call names the host; the
direct, cloud-relay and shell-bridge transports now carry `executionHost`, streams keep it for
replay and unsubscribe, and an older shell (no `executionHost` grant) refuses rather than strips it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete

A server Orca manages on an SSH host now lists once under that host's name, or not at all while
the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is
deleting are left out at reply time, as a phone asking the server itself would see.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch

Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied
the phone's list; rows are now pruned only once a server is no longer configured. Fetches are
shared per server identity, so a poll after a re-pair asks the new server right away.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the paired desktop's server workspaces on the phone

Fixes the phone missing a desktop's `orca serve` workspaces: rows come from the desktop's
mobileRelay.hosts.* with each server's health ("Update needed", "Disconnected"), open through the
relay, follow the desktop's hidden hosts, and the desktop now advertises the relay capability.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a desktop failing the hosts list leaves only its own rows

The page asks a relaying desktop for its servers; a refusal such as method_not_found must leave
today's list with no server rows and no catalog error.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): mirror the desktop's shown hosts exactly, one host included

Jinwoo's call: the phone follows visibleWorkspaceHostIds as the desktop sidebar does, so a
one-host list narrows the phone too instead of being read as a single-host focus.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): scope a server workspace at its route, never the desktop

Every screen under a server workspace's route now reads one context and
gets that server's client and a router that keeps pushes on it, refusing
to fall back to the desktop; pins write to the row's owner like the Mac.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never open an unreachable server workspace, and prove the relay in e2e

A server workspace the phone can't reach now gets one explicit screen at its route
and no row tap or action-sheet entry opens it; native routes read the route's host,
a census pins every workspace route, and the e2e reads files and git on the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): gate a server workspace on that server's own features

A targeted status.get now relays to the server, so a server workspace's route
provides that server's status to every screen under it and its action sheet,
while whether the phone can reach the server stays the desktop's answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(ci): run the remote-runtime WebSocket opener test on Node

Under the bun vitest project, `ws` resolves to Bun's built-in client, which
ignores maxPayload, so the frame-cap test hung. The opener only ships on
Node/Electron, so run its test in the Node runtime project like its siblings.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run a server workspace's SSH calls on that server

A phone in a server workspace asks for SSH state before a file mutation, but the Mac answered
about its own targets, so server-owned SSH repos failed. The desktop asks the server, so the
ssh.* family now routes by target; an e2e pins files, uploads, commits and review on the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the server files/git e2e offline and baseline-git safe

Review eligibility reached real gh/glab over the network and proved nothing beyond repoSlug;
setup git now ignores the runner's config and avoids `init -b` (Git 2.25). The relay
integration test is the routing oracle, so the census re-listing goes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the file preview the workspace router, back to 128 page assets

The preview was the one workspace screen on the plain router, so the scoped router
was reached by a route set of its own and esbuild split it into an extra chunk
(129 assets); every workspace screen now uses it and it shares the route's chunk.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): derive the phone allowlist from the routing table

Adding a phone method now takes one edit (its route). A server-targeted call on a
legacy non-E2EE socket is refused instead of running locally, and only
execution-host methods enter the relay.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): drop unread freshness fields from server workspace lists

The phone reads only the rows, so stale, fetchedAt, totalCount and truncated
were dead wire weight. The desktop still serves a server's last rows while
the server is unreachable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read only the rows of a server's workspace list

The desktop no longer sends a freshness flag, so the phone's schema drops it.
The relay capability list names the delegated-devices constant instead of
repeating its literal, which also retires a now-circular type check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): open server-workspace diffs and file taps on the phone's own screens

A headless orca serve has no renderer, so files.openDiff / files.open answer
renderer_unavailable and the Changes tap showed that raw code. On that refusal the
phone now opens its review screen (git.diff) or file preview (files.read) instead.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show a server workspace as disconnected while its server is down

The phone read the desktop's connection state for a server workspace, so a stopped
server kept a green dot and "Loading tabs" spun forever. The workspace route now
narrows that state by the server's health from mobileRelay.hosts.list, reusing the
existing disconnected session UI and refetching tabs when the server returns.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop Open in Session and preview new notes in server workspaces

A server workspace's host has no editor tabs, so the review screen no longer offers
Open in Session there, and a new markdown note whose files.open answers
renderer_unavailable opens in the file preview instead of a raw error toast.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): share one desktop server-list feed between the host list and workspaces

A server workspace polled mobileRelay.hosts.list beside the host list's own read, so
the phone could ask the desktop twice per tick and the row badge and the screen could
disagree. One feed per desktop client now owns that read; both readers join it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): read a server's reachability from the hosts list, not a dropped stale flag

eff410fe30 dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's
schema never parsed and its first poll timed out. It now waits on the server's
relay/health from mobileRelay.hosts.list, the field the phone itself reads.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): re-measure the page's script sweep for server workspaces

Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace
route's shared modules split importer sets three ways more, reaching 81. Re-swept per
route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): export the server-list feed's React reader beside the feed

useRelayHosts moves from workspace-server.ts into relay-hosts-feed.ts, so every reader (workspace screens now, the new-workspace sheet next) subscribes through the feed's own module. No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): stop requiring the dropped stale flag in the files and git review spec

eff410fe30 removed stale from mobileRelay.hosts.worktrees, so the schema never parsed and the row poll at :100 timed out.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hide New Note where the host opens no editor tabs; drop stale pushes

A server workspace's host has no desktop window, so New Note left an unopenable
untitled.md each tap. It now hides under the same route rule as Open in Session, and
the no-window fallbacks no longer push a screen after the user has moved on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name the editor-tab gate for what it measures

useWorkspaceOnPairedHost says the workspace runs on the paired desktop itself; its doc
states the rule (hide actions whose only result is an editor tab) and its known gap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep polling the server list and read a server's reachability from relay

A hosts-list read slower than the poll stopped the feed for good, since the next tick was
scheduled when the read started; it is now scheduled from the finish. A server workspace now
reads connected only when the desktop relays to it, so a server with no status yet is not shown up.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show a reached server as connecting and a too-old one as disconnected

A server the desktop has reached but whose status is not answered yet is moments from relaying,
so it reads connecting; a server too old to relay to never will, so it reads disconnected.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(shared): move the account capabilities into their own group module

Main's merge put protocol-version.ts at 301 lines, over the 300 cap. The account group moves out
like the relay and skill groups did, same strings in the same order; the page's script sweep is
re-measured because the split changed its chunking (81 to 79 at 16 routes).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name a server workspace through the request envelope

The server name now rides the generic request envelope from #27815 instead of
its own executionHost option, bridge grant and per-transport plumbing. Server
rows show only where the transport carries an envelope (native, or a shell
that grants rpcEnvelope), so an older app keeps today's list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the server-call wire and the no-envelope deep link

A server workspace call still reaches the desktop as the same top-level
executionHost frame, on the direct and relay wires, and a deep link into a
server workspace under an app without rpcEnvelope shows the unreachable screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read a relay stream's envelope slot in sorted key order

Equal envelopes name one cleanup slot whatever their key order, the scoped view
passes its client's envelope answer through as it does the generation, and the
direct wire harness is shared instead of rebuilt.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): a phone's new chat in a server workspace follows the desktop's chat setting

A relayed agent.launch ran on the server, which picked chat vs terminal from its
own setting. The desktop relay now stamps its own default as nativeChatDefault;
new servers use it in place of their setting (feasibility still decides), older
servers strip it. Outside the replay fingerprint.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(mobile): move the relay's delegated phone grant cache into its own module

Keeps mobile-desktop-relay.ts under the line limit once #27749's page relay lands on it.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mobile): show the early terminal tab from the relaying desktop's chat default

The early-tab preflight read the server's own chat setting while the launch
used the desktop's stamped nativeChatDefault, so on a windowed server with
chats off a relayed chat launch flashed a terminal tab. Both now read the
settings through readAgentLaunchModeSettings(runtime, nativeChatDefault).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 01:25:03 -04:00
Jinwoo HongandClaude 5f134d8f01 feat(mobile): show and use the desktop's server workspaces on the phone (#26501)
* test(e2e): add a paired phone client helper and phone-mirror baseline specs

Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the
phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve
restart and today's gap: a desktop's phone does not list the desktop's server workspaces.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): show SSH host health on the workspace row host badge

Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): compose the phone-mirror topology and check the cold snapshot

Slices need one composer for host, desktop and phone with a single teardown, so specs stop
copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold
start, which is what the phone builds its tab strip from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name an unhealthy SSH host even in a single-host list

The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): keep phone-mirror teardown errors from masking test failures

Teardown failures become a test attachment instead of replacing the real failure, and the
cold-snapshot poll reports stream errors together with the PTY list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): clear stale SSH host badges and announce them without the dot

A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(runtime): let a paired desktop register its phones as delegated mobile devices

A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of
its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the
desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(runtime): move the mobile device revoke primitive below dispatch

Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in
a layer under it as a pure move; this drops the abstract method seam. No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): keep a runtime grant until its delegated phones are revoked

A failed child cleanup now leaves the parent in place so a retry can still find the children, and
revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage,
shares credential minting, and drops the timing-dependent census.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): fail a delegated sync whose dropped phone cannot be revoked

Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors
before minting anything, so the desktop retries instead of believing the server matches its list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): relay a paired phone's server-workspace requests through the desktop

A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop
now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per
(phone, server), signed in as the phone's delegated device; replies pass through untouched except
stream ids. Hidden until the phone client ships: the relay capability is not advertised yet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the single-host health fixtures inline so the test tsc passes

Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the desktop relay contract in its entry module

main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a
server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a
later request syncs it again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the relay's configured-server adapter

The production hosts adapter was only typechecked; this pins that it resolves configured servers
by exact id only (never a name or a removed server), fences by pairing revision and runtime
identity, and stops reporting retirements once unsubscribed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the repo-metadata goldens for SSH host health

The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): tighten the desktop relay after elegance review

Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request
whose link ended while opening, passes failure codes through, relays only this desktop's own
phones, and drops the unused host-state reader until S2 needs one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound relay frames and stop sticky relay verdicts

Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on
each new link so an in-place upgrade relays, answers a phone the server just declined without
syncing again, and ends a link whose mirrored capability update cannot be queued.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run targeted desktop-owned calls on the desktop

The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the desktop's configured servers and their last workspace rows for the phone

The phone needs the same host list the desktop shows, including servers that are offline or
too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and
its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): list server rows through one desktop fetch, with no side effect on listing

The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the
desktop's own fetch is both the live source and the cache, and stamping stays in main.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers

The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows
when a server hangs, drops a server's rows when it is removed or re-paired, and checks the
server's identity after the fetch so a reply from before a re-pair is never served.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name a server workspace's host on the phone's calls

A phone reaches a server's workspace through its desktop only if each call names the host; the
direct, cloud-relay and shell-bridge transports now carry `executionHost`, streams keep it for
replay and unsubscribe, and an older shell (no `executionHost` grant) refuses rather than strips it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete

A server Orca manages on an SSH host now lists once under that host's name, or not at all while
the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is
deleting are left out at reply time, as a phone asking the server itself would see.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch

Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied
the phone's list; rows are now pruned only once a server is no longer configured. Fetches are
shared per server identity, so a poll after a re-pair asks the new server right away.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the paired desktop's server workspaces on the phone

Fixes the phone missing a desktop's `orca serve` workspaces: rows come from the desktop's
mobileRelay.hosts.* with each server's health ("Update needed", "Disconnected"), open through the
relay, follow the desktop's hidden hosts, and the desktop now advertises the relay capability.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a desktop failing the hosts list leaves only its own rows

The page asks a relaying desktop for its servers; a refusal such as method_not_found must leave
today's list with no server rows and no catalog error.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): mirror the desktop's shown hosts exactly, one host included

Jinwoo's call: the phone follows visibleWorkspaceHostIds as the desktop sidebar does, so a
one-host list narrows the phone too instead of being read as a single-host focus.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): scope a server workspace at its route, never the desktop

Every screen under a server workspace's route now reads one context and
gets that server's client and a router that keeps pushes on it, refusing
to fall back to the desktop; pins write to the row's owner like the Mac.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never open an unreachable server workspace, and prove the relay in e2e

A server workspace the phone can't reach now gets one explicit screen at its route
and no row tap or action-sheet entry opens it; native routes read the route's host,
a census pins every workspace route, and the e2e reads files and git on the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): gate a server workspace on that server's own features

A targeted status.get now relays to the server, so a server workspace's route
provides that server's status to every screen under it and its action sheet,
while whether the phone can reach the server stays the desktop's answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(ci): run the remote-runtime WebSocket opener test on Node

Under the bun vitest project, `ws` resolves to Bun's built-in client, which
ignores maxPayload, so the frame-cap test hung. The opener only ships on
Node/Electron, so run its test in the Node runtime project like its siblings.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the file preview the workspace router, back to 128 page assets

The preview was the one workspace screen on the plain router, so the scoped router
was reached by a route set of its own and esbuild split it into an extra chunk
(129 assets); every workspace screen now uses it and it shares the route's chunk.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): derive the phone allowlist from the routing table

Adding a phone method now takes one edit (its route). A server-targeted call on a
legacy non-E2EE socket is refused instead of running locally, and only
execution-host methods enter the relay.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): drop unread freshness fields from server workspace lists

The phone reads only the rows, so stale, fetchedAt, totalCount and truncated
were dead wire weight. The desktop still serves a server's last rows while
the server is unreachable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read only the rows of a server's workspace list

The desktop no longer sends a freshness flag, so the phone's schema drops it.
The relay capability list names the delegated-devices constant instead of
repeating its literal, which also retires a now-circular type check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show a server workspace as disconnected while its server is down

The phone read the desktop's connection state for a server workspace, so a stopped
server kept a green dot and "Loading tabs" spun forever. The workspace route now
narrows that state by the server's health from mobileRelay.hosts.list, reusing the
existing disconnected session UI and refetching tabs when the server returns.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): share one desktop server-list feed between the host list and workspaces

A server workspace polled mobileRelay.hosts.list beside the host list's own read, so
the phone could ask the desktop twice per tick and the row badge and the screen could
disagree. One feed per desktop client now owns that read; both readers join it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): read a server's reachability from the hosts list, not a dropped stale flag

eff410fe30 dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's
schema never parsed and its first poll timed out. It now waits on the server's
relay/health from mobileRelay.hosts.list, the field the phone itself reads.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): re-measure the page's script sweep for server workspaces

Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace
route's shared modules split importer sets three ways more, reaching 81. Re-swept per
route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): export the server-list feed's React reader beside the feed

useRelayHosts moves from workspace-server.ts into relay-hosts-feed.ts, so every reader (workspace screens now, the new-workspace sheet next) subscribes through the feed's own module. No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep polling the server list and read a server's reachability from relay

A hosts-list read slower than the poll stopped the feed for good, since the next tick was
scheduled when the read started; it is now scheduled from the finish. A server workspace now
reads connected only when the desktop relays to it, so a server with no status yet is not shown up.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show a reached server as connecting and a too-old one as disconnected

A server the desktop has reached but whose status is not answered yet is moments from relaying,
so it reads connecting; a server too old to relay to never will, so it reads disconnected.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(shared): move the account capabilities into their own group module

Main's merge put protocol-version.ts at 301 lines, over the 300 cap. The account group moves out
like the relay and skill groups did, same strings in the same order; the page's script sweep is
re-measured because the split changed its chunking (81 to 79 at 16 routes).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name a server workspace through the request envelope

The server name now rides the generic request envelope from #27815 instead of
its own executionHost option, bridge grant and per-transport plumbing. Server
rows show only where the transport carries an envelope (native, or a shell
that grants rpcEnvelope), so an older app keeps today's list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the server-call wire and the no-envelope deep link

A server workspace call still reaches the desktop as the same top-level
executionHost frame, on the direct and relay wires, and a deep link into a
server workspace under an app without rpcEnvelope shows the unreachable screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read a relay stream's envelope slot in sorted key order

Equal envelopes name one cleanup slot whatever their key order, the scoped view
passes its client's envelope answer through as it does the generation, and the
direct wire harness is shared instead of rebuilt.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): a phone's new chat in a server workspace follows the desktop's chat setting

A relayed agent.launch ran on the server, which picked chat vs terminal from its
own setting. The desktop relay now stamps its own default as nativeChatDefault;
new servers use it in place of their setting (feasibility still decides), older
servers strip it. Outside the replay fingerprint.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(mobile): move the relay's delegated phone grant cache into its own module

Keeps mobile-desktop-relay.ts under the line limit once #27749's page relay lands on it.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mobile): show the early terminal tab from the relaying desktop's chat default

The early-tab preflight read the server's own chat setting while the launch
used the desktop's stamped nativeChatDefault, so on a windowed server with
chats off a relayed chat launch flashed a terminal tab. Both now read the
settings through readAgentLaunchModeSettings(runtime, nativeChatDefault).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 23:35:33 -04:00
6aef5c569c Phase 3.7: fix SSH/remote failures found by adversarial real-host testing (#26847)
* fix(remote-terminal): keep a reconnected pane's size after an SSH outage (P1-4) (#26625)

* fix(remote-terminal): keep a reconnected pane's grid when its stale stream is reaped (P1-4)

The host's width floor only handed ownership to viewers that had claimed
since subscribing. A reconnecting desktop pane re-subscribes without
claiming, so when the heartbeat later reaped the dead socket that owned the
floor, the host reclaimed the PTY to its spawn size (120x40) and held the
pane there. The owner's same-client replacement stream now inherits the
floor, and a pane whose stream was reaped before it reconnected resumes
ownership on re-register unless the host or another viewer claimed since.

* fix(remote-terminal): host typing during an outage revokes the reaped owner's resume

Host input reaches the PTY without a renderer viewport claim once the host has
reclaimed its grid, so the orphaned-owner entitlement survived and a passive
reconnect resized the host pane back to the remote grid. The host write path
now clears that entitlement on driving input (query replies, focus reports and
launch writes excluded).

* test(pty): stub the runtime's host-input note in write-path handler tests

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(remote): an oversized RPC reply fails that request instead of closing the host connection (#26624)

Expanding a folder with ~80k entries on a managed SSH host produced a
files.readDir reply over the 4 MiB encrypted-reply cap. The channel treated
that as a transport fault and closed the whole control socket (1013), and the
explorer rendered the failed folder as empty.

- The WebSocket RPC transport now answers an over-cap reply with a
  reply_too_large error for that request id, so the channel never sees it.
- files.readDir checks remote listings against the request's content budget
  and names the entry count.
- The explorer shows a subfolder's read error on its expanded row.

Finding: HH-2 (Phase 3.7 hostile-host).

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): let a slow host finish installing the pinned Node runtime (HH-3) (#26631)

* fix(ssh): let a slow host finish installing the pinned Node runtime

The POSIX promote (extract, hash, self-test and publish the ~110 MiB node
binary) ran under the 30 s default exec timeout. On slow home storage it
timed out, the remote work kept running, and the runtime store lock it held
stayed until the 20-minute stale rule, so relay rung A queued behind it past
its own deploy bound.

Give both platforms the 300 s promote bound and have the host release the
store lock itself when the promote exits, with owner-checked releases so a
late release never removes a lock someone else took. (HH-3)

* fix(ssh): let the host release the store lock after a slow under-lock re-hash

The under-lock re-check of a sibling-published runtime hashed the ~110 MiB
executable as its own command under the 30 s exec default and outside the
host-side lock release. On slow storage it timed out, the client correctly
kept the lock, and nothing on the host freed it until the 20-minute stale
rule, so later installers ran out their deploy bound.

Run the re-hash and the conditional promote as one host command wrapped in
the host release, and give every full-runtime hash, including the initial
probe, the slow-storage bound. (HH-3)

* test(ssh): count the combined under-lock re-probe and promote as one host command

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): shell startup output on stdout no longer breaks SSH connect (HH-1) (#26617)

* fix(ssh): fence captured exec stdout so shell rc output cannot break connect

sshd runs the user's login shell before Orca's /bin/sh wrapper, so any
startup-file stdout (fish/tcsh/zsh/bash echo, or a bare terminal-title escape)
was glued onto every captured answer. The home probe read "banner\n/home/u"
and connect failed with "Remote home is not a valid path".

execCommand now wraps wrapped POSIX commands in a per-call nonce fence and
returns only the payload between the markers; the SSH folder browser gets the
same fence. The fence is the existing WSL captured-login-shell fence, moved to
src/shared/posix-stdout-fence.ts so both transports share it.

Finding: HH-1 (phase 3.7 hostile-host). Refs #8324.

* fix(ssh): fence the login-shell Node probe and fall back from a noisy SFTP handshake

The login-shell 'command -v node' probe ran unwrapped, so output from sshd's
shell and the nested login profile became the Node path. It now prints a
one-line fence every login shell parses, inside that shell.

An external sftp-server is started through the user's shell, so startup output
corrupts the SFTP handshake. ssh2 reports that parse failure only before the
session is ready, so SshConnection.sftp tags it, and relay install transfers
treat it as SFTP unavailable and stream over exec stdin.

Finding: HH-1 follow-up (phase 3.7 hostile-host). Refs #8324.

* fix(ssh): keep the stdout fence marker out of the command text

The runtime-store inventory lists processes with ps, which prints its own
script, so a literal marker in the script was read as the fence and the
store GC skipped every pass. printf now joins the marker from two halves.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): finish an abandoned update on connect, and refresh renderers after a main-side re-pair (P1-A, P1-C) (#26635)

* fix(orcad): finish an update or rollback its client abandoned, and tell renderers about main-side re-pairs

P1-A: a wake that finds the activation fence now runs crash recovery, which takes
only a stale, previous-boot or exited-own fence. Recovery of an activation
interrupted after the candidate launched resumes it: the candidate commits if it
passes the readiness gate, else the activation is undone as before.

P1-C: invalidating a still-linked environment's transport restarts its status
owner, and a renderer that sees status under a newer pairing revision re-reads
the catalog before applying it.

* fix(orcad): a resumed update keeps the app version that started it

Recovery that commits an abandoned candidate stamped no activeAppVersion, so an
older desktop read the host as pre-tracking and downgraded it over state the
newer build had migrated. The activate journal now records the initiating app
version; recovery stamps it, and a journal too old to carry it is undone instead
of committed.

* test(runtime-env): answer nothing for the status restart a re-pair now triggers

* fix(orcad): recovery leaves running an update server a too-old journal cannot commit

A journal without candidateAppVersion was sent to undo, which stopped a
healthy candidate that had already migrated state and then refused to
restart the incumbent, leaving the host serving nothing. Refuse instead,
keeping the fence and the candidate, until the operator accepts Restore.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(terminal): a remote recovery never splices stale history above the new screen (P2-5, P2-7) (#26647)

* fix(terminal): a remote recovery never splices stale history above the new screen

A pushed remote snapshot (overflow recovery, seq-gap resync, first attach) is
sent because the client's stream broke, so the pane's own history ends before
that image. Since #26319 a screen-only image kept that history, splicing a
stale block directly above the final screen with a silent gap of up to ~270k
lines (P2-5), and a first attach to an older host left silent holes (P2-7).

- Host: the ACK-overflow recovery serializes up to 1000 history rows.
- Client: the seq-gap resync asks for the same 1000 rows.
- Client: a pushed snapshot always replaces the pane's scrollback; the
  keep-local-scrollback path is removed. A screen-only image from an older host
  now shows only its screen, which is lossy but never misleading.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(terminal): a pushed image replaces the normal buffer over a live alt screen too

Over a pane already on the alternate screen, an image that also ended on alt
repainted only its alt frame and kept the pane's normal buffer, inferring the two
had frozen together. Inside the gap the TUI can exit, the shell print and a second
TUI start, so once that TUI exited the pane showed its stale history where the host
had the missed output. A pushed image now always rebuilds the normal buffer and
history from the host before entering alt itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(terminal): bound recovery history by link slack and keep pre-TUI scrollback under screen-only images

A recovery that carries history now goes out only when the stream's ACK
window has drained; under a sustained flood recoveries stay screen-only and
the owed history follows once the client catches up, so snapshot bytes no
longer outgrow the client's intake.

A pushed image without history (scrollbackRows absent or 0) over a live alt
screen repaints only the alt frame again, so a reconnect to an older host
no longer wipes the pane's pre-TUI scrollback.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(source-control): Stage All stages every change when the listing hit the 1,000 cap (HH-4) (#26618)

* fix(source-control): Stage All stages every change when the listing hit the 1,000 cap

The status listing stops at 1,000 rows, but Stage All built its path list
from those rows, so it staged 1,000 of 20,000 changes and the next commit
was silently partial. When the listing is capped, Stage All (and the
Changes header Stage all) now asks the host to stage the whole worktree
via an optional scope on git.bulkStage; unresolved conflicts stay
unstaged. Older hosts ignore the field and stage the listed paths.

Fixes HH-4.

* fix(source-control): whole-worktree Stage All needs a host receipt, skips shared links, and reaches mobile

- Scoped stage sends no paths and requires a stagedScope receipt, so an older
  runtime or relay stages nothing and the client reports it instead of a
  silent partial stage.
- Scoped 'all' skips Orca's shared symlinks (configured and really a link),
  matching the status listing.
- The capped-listing banner offers Stage All, reachable when the visible
  prefix is entirely staged.
- Mobile reads didHitLimit and uses the same scoped stage and receipt.

* test(source-control): type the Stage All test stubs so lint and the mobile test typecheck pass

* fix(source-control): match Shared Paths saved as ./name or name/ against git's spelling

The Shared Paths setting keeps entries as typed, but git prints worktree paths
without a leading ./ or trailing /. Stage All's conflict path compared the raw
spelling to git's untracked list and staged the shared node_modules link, and
an exclude pathspec ending in / made git refuse the add after tracked changes
were already staged. Status listing had the same mismatch. getSafeRelativePath
now returns git's spelling, so every caller gets it, including saved settings.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): redeploy a managed server another desktop stopped; forget one whose host is gone (P1-D, P1-E) (#26628)

* fix(ssh): redeploy a server another desktop stopped; let a dead managed host be forgotten

P1-D: a connect whose linked server the host reports as not activated (another desktop
stopped it) now goes on to the update check, which redeploys it in place.
P1-E: add Forget (managedServer.forget, orca environment forget, Settings row) that runs
Stop's local unlink without contacting the host; the verdict stays unverifiable.

* test(ssh): type the forget test's hoisted store without an assertion

* fix(ssh): never downgrade a stopped host; redeploy it mid-session and retry after a failure

A stop now keeps the stopped build's Orca version, so an older desktop's
reconnect skips as host-newer instead of starting an older server on a
store a newer build migrated. A stop recorded without that version is not
guessed at. A stopped host retries the redeploy on each connect instead of
the once-per-version failure suppression, and a runtime call that finds
the server stopped mid-session redeploys it before the call.

* fix(ssh): rebuild the managed forward after redeploying a stopped host

A redeploy can bind a different port than the reused forward targets.

* fix(ssh): keep stop journals readable by older desktops and recheck version policy under the fence

- The stop journal's recordAfter is the core deactivated record older peers compute; the
  committed record keeps activeAppVersion (which released planners already honour) and
  rolledBackFrom, rebuilt from recordBefore on recovery.
- Auto-update re-applies its version policy to the record read under the host fence, and a
  census counted against another incumbent is treated as unverifiable.

* fix(ssh): file a dispatched stop where older desktops cannot commit it, so their recovery never drops the version guard

* fix(ssh): refuse a fresh deploy of an older orcad onto a host a newer desktop stopped

The unlinked deploy path never consulted the stopped record's activeAppVersion, so a desktop
without a link to the host could activate its older build over state a newer build migrated.
Both the auto-update planner and the fresh deploy now apply one downgrade rule under the fence.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): redeploy a managed server a pre-version Stop left; let a forced deploy run over a newer one (#26766)

A stopped activation record without activeAppVersion is what every Stop on
main writes, so refusing it stranded those hosts behind an unforceable
policy refusal. Only a provably newer host is now refused; a forced fresh
deploy runs this version anyway, and the refusal names both versions.

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(remote-terminal): no keystroke lost, duplicated or split across a silent outage (P1-1, P1-2, P1-3) (#26630)

* fix(remote-terminal): no keystroke lost, duplicated or split across a silent outage

Remote pane input was fire-and-forget: keys handed to a socket that had
silently died were gone, keys still in the 8 ms debounce at detection were
cleared, and held keys were discarded when the auto-recovery window ran out.

- New negotiated `inputAck` capability: the client names an input session
  and sequences Input frames (existing frame header `seq`); the host records
  the last applied sequence per (PTY, session) across connections, writes
  each sequence once and acks it with new opcode InputAck (18), sent only
  to clients that advertised the capability.
- The pane keeps sent-but-unacked input and replays it to the same
  terminal on reattach; the host dedupes. Old hosts keep current behavior.
- Debounced and claim-queued input at detection is held, not cleared.
- Held input survives the 'disconnected' phase; the hold coalesces keys so
  minutes of typing do not hit the per-entry cap.
- Banner copy no longer claims a one-minute retry window.

Fixes P1-1, P1-2, P1-3 (phase 3.7 network findings).

* fix(remote-terminal): never replay into a restarted runtime; keep Ctrl+C and validating paste in order

- Host publishes inputLedgerId with inputAck; the client drops unacknowledged input sent to a
  different ledger (runtime restart with the same PTY) instead of running it twice.
- A replayed duplicate is acked only after the original write settles.
- Accepted input (Ctrl+C, Escape, paste) rides the sequenced stream and resolves on the host ack.
- Keys typed while held queue behind a paste still in size validation.

* fix(remote-terminal): parse subscribed capabilities into a typed shape instead of Reflect.get

* fix(remote-terminal): ack only applied input; withdraw writes their caller gave up on

- Host ledger records each write's outcome: a refused write is rolled back and
  never acked, so the client's replay writes it; nothing queued behind it runs or
  is acked. A write that threw is acked as delivery-unknown, so the client
  reports it failed and never replays it.
- Paste and file-drop timeouts abort their accepted write: a copy still held for
  an outage is withdrawn, and a journaled copy is left out of the replay.
- A batch of sequenced input keeps one endpoint and ledger, so a send that
  retires the stream mid-batch no longer discards earlier unacked input.

* fix(remote-terminal): host writes sequenced input strictly in order and asks the live stream to resend after a refusal

- The ledger admits only the next sequence; a refusal or gap answers with an InputAck resend request instead of WriteUnavailable, so the pane keeps its journal and resends on the same stream (no remount, no carrier bookkeeping).
- Sequenced input uses the provider's settled write; an unverifiable handoff is delivery-unknown, not refused.
- Replays fill given-up slots with empty input so the host can continue; the client remounts only after every resend backoff is refused.
- Typing batched ahead of a paste goes out as its own sequences, so cancelling the paste keeps it.

* refactor(remote-terminal): deliver sequenced input with standard go-back-N

Replace the hand-built "block on refusal" bookkeeping with go-back-N:

- Host: per (PTY, input session) the ledger keeps only appliedSeq, the
  highest contiguous confirmed write. Frames run one at a time in arrival
  order: a duplicate is re-acked, the next sequence is written and acked
  on confirmed success, and a refusal or gap is answered with a resend
  request carrying appliedSeq. No blocked state; every stream is equal.
  A settlement that proves no byte left is a refusal; one that may have
  written advances as delivery-unknown and is never retried.
- Client: one journal keeps unacked input in order and resends from
  appliedSeq + 1 on a resend request (with backoff), on a new stream, or
  when acks stall. Refusals never remount the pane. Withdrawn pastes,
  stale query replies and over-budget bytes resend as empty slots.
- The recovery hold pauses (keeps the rest) when a release is cut short
  by another outage, and keeps delivering typing after an accepted write
  a sequenced stream withdrew or could not confirm.
- Add a seeded model-based test that drives the real pane transport,
  multiplexer and journal against the real host dispatcher and ledger
  through 2,000 random fault schedules.

* test(remote-terminal): cover the refusal remount on a stream without sequenced input

A sequenced stream now resends refused input on the same stream instead of remounting, so the end-to-end remount check negotiates no inputAck, as an older peer does.

* fix(remote-terminal): escalate persistent input refusals, drop input once recovery gives up, and never report a lost unknown ack as applied

- The pane falls back to its remount path when the host keeps refusing input past the resend backoff ladder.
- Held and journaled input is discarded when auto-recovery gives up, and input typed under the disconnected banner is not held.
- The host ledger answers a duplicate for its own sequence and remembers delivery-unknown outcomes; the client settles a write as applied only from its own ack.
- The input model now requires a write reported applied to have reached the PTY whole, and covers a refusal that never clears.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Claude <noreply@anthropic.com>

* fix(remote-terminal): keep input typed past the auto-recovery window for the same terminal, within a bounded grace (P1-3) (#26832)

The last round of #26630 discarded held and journaled input as soon as auto-recovery gave up,
and refused input under the disconnected banner. On a real host a 200 s outage lost 102 of
120 keystrokes, although the pane reattached the same terminal on its own seconds later.

Input now stays held for the same handle and incarnation while the pane is disconnected, for
up to 5 minutes after it gave up. Past that grace it is discarded, and typing into the dead
pane is refused, so stale keys never run at an arbitrary later reconnect. The grace is a
timestamp checked when input moves, so a latched pane stays free of timers.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-10-09 04:58:48 -07:00
Neil 761092081d Avoid rebuilding committed mobile files while typing (#26196) 2026-10-07 22:41:33 -07:00
Neil 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.
2026-09-29 15:53:18 -07:00
Neil 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.
2026-09-29 01:21:53 -07:00
Brennan Benson 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 514ab7f868 and re-recorded all goldens. Against the branch
point: 781 header-only (baseline on all; adapterSha256 on the 48 goldens whose
adapter module changed; scenarioSha256 on 3), one body moved
(pr-triage-launch: createTerminal + terminal.send becomes agent.launchReplay),
eight added (the new launch outcomes and their reply matrices) and five deleted
(the shell-path scenarios and their matrices).

* fix(mobile): show review notes' agent launch progress and failures, once

"New Agent Session" left the sheet open with no progress for the whole launch
(up to a minute while a terminal agent readies), so a second tap started a
second agent, and a launch that never started or could not be confirmed
rejected an unobserved promise, showing nothing. The sheet now closes on tap,
the review screen says "Starting an agent...", one launch runs at a time, and
every outcome lands in the review screen's status line.

Marking notes sent now reads the screen state when the launch settles, so a
note written during the wait is not dropped by the whole-list save.

* test(mobile): re-record goldens for review notes' launch outcome on the review screen

Repins the corpus to 6f3018576d. One golden body moves:
review-create-agent-refused now fulfils with "Workspace not found" in the
review screen's status line and the sheet closed, where it previously
rejected an unobserved promise and left the status line empty. The other
789 goldens move only their baseline header.

* test(agent-status): drop the retired PR-triage terminal send from the identity inventory

The phone's AI buttons no longer create a terminal and send the prompt into it
(`createTerminalAndSendPrompt` is gone); the host's agent launch delivers it.
There is no terminal action consumer left in that file to pin.

* fix(runtime): publish saved source-control launch recipes to paired clients

settings.get is an allowlist and omitted sourceControlAi, so the phone never
saw an agent saved globally for "Fix checks", "Resolve conflicts" or commit
recovery and always fell back to the default agent. The host now publishes
the launch actions' recipes (agent, prompt template, agent args), normalized
so legacy saved defaults are already migrated. A new optional reply field:
older clients ignore it, and a client talking to an older host sees none and
keeps using the default agent.

* fix(mobile): ask to update Orca only when the host answered without agent launch

An unread or failed status read settles with no capabilities, which the AI
buttons read as an old host and showed "Update Orca on your computer". The
update copy now needs a status the host actually returned; an unread one keeps
the buttons disabled without blaming the desktop's version.

* fix(mobile): send an AI button's saved agent arguments with its launch

The desktop's direct launches for "Fix checks", "Resolve conflicts" and commit
recovery pass the action's saved agent arguments to agent.launch; the phone
honoured the saved agent but dropped its arguments. It now sends them the same
way: absent when none are saved, so the host keeps the user's configured
defaults. A host that predates the field ignores it.

* test(mobile): repin the RPC recording corpus after the launch recipe and availability fixes

Repins to fe85e0346f. All 790 goldens move only their baseline header: no
scenario saves agent arguments or reads an unreadable status, so no recorded
behaviour changes.

* fix(mobile): say the host status is unreadable instead of nothing when it is

With the update copy now reserved for a host that answered without agent
launch, an unread status left the AI buttons disabled with no explanation.
They now say "Could not read this host's status. Go back and reopen it.", the
words the mobile web shell already uses for the same failure; leaving the host
re-reads its status.

* fix(mobile): wrap an AI button's prompt in the action's saved template

The desktop renders every source-control launch's prompt through the action's
saved template (buildSourceControlRecoveryAgentCommandInput); the phone sent
its built-in prompt as is. Now that the host publishes the recipes, the phone
renders through the same shared function, refuses an empty result as the
desktop does, and offers the rendered text when it could not be delivered.
Review notes have no recipe and are unchanged.

* test(mobile): re-record goldens for the templated AI-button prompt

Repins to 7fd1555d20. One golden body moves: pr-triage-prompt-not-delivered
now carries the prompt as sent (rendered through the action's template) on its
prompt-not-sent result, which is what Copy prompt offers. The other 789
goldens move only their baseline header.

* fix(mobile): re-read a host status that failed while the connection stayed up

A status.get that timed out or was cut over settled the host's gates closed
and was never asked again until the connection state changed, so the phone's
AI buttons stayed disabled behind "Could not read this host's status" on a
link that was working. The gate still settles closed at once, so a failed
read never holds the host screen, but it now re-asks in the background with
the same backoff the runtime capability probe uses, and opens once a status
lands. A reply this app cannot decode is not re-asked.

* test(mobile): repin the RPC recording corpus after the host status re-read

The status gate change moves no recorded behavior: every golden's body is
unchanged and only its baseline header moves to the new pin.

* fix(mobile): show a launch's host warning as a note, not an error

A launch that went ahead can carry a host warning (a structured chat ignores saved agent
arguments, including the '' a template-only save writes). The AI buttons rendered it in the red
error line beside a success haptic. The notice now carries it separately as secondary text, and
review notes keep saying they were sent. Commit recovery also takes the synchronous in-flight lock
the PR triage buttons use, so two taps before a re-render start one agent.

* chore(mobile): record the host status re-read timer for React Doctor

The status re-read arms one retry timer from inside its read and clears it in the effect's
cleanup. React Doctor reports that self-rescheduling shape even in its minimal form, which failed
both changed-lines gates. Suppressed the same way as the session startup timers.

* fix(mobile): say review notes are waiting for the desktop instead of doing nothing

With no live connection, New Agent Session threw from a handler whose promise the sheet drops, so
the tap did nothing visible while the button stayed enabled (proven capabilities survive a drop).
It now closes the sheet and shows "Waiting for desktop..." as the other AI buttons do.

* fix(mobile): stop sending an AI button's saved agent arguments

Whether saved arguments apply depends on the route and shell the host settles after the request
(a chat ignores them and warns; malformed ones fail after admission), and the desktop sends them
only when they apply. The phone cannot know that, so it now leaves them out and the agent's default
arguments apply, as before this series. The saved agent and prompt template still apply.

* fix(mobile): mark review notes sent through the latest save

The sent marks after an agent launch went through the save callback captured at tap time, whose
rollback restores the screen from that moment, so a failed save could drop notes written during
the launch. It now uses the latest render's save, as it already did for the screen state.

* refactor(mobile): own the host status re-read outside the effect

The re-read loop lived inside the effect body, so React Doctor could not see its cleanup and
needed an inline suppression plus a config allowlist entry. The loop is now a plain function that
returns its stop handle, and the effect returns that handle, the same shape every caller of the
runtime capability probe uses. Both suppressions are removed; behaviour is unchanged.

* test(mobile): record the host descriptor from a background status re-read

Pins that the status read records the host descriptor when a re-read succeeds after a failed first
read, not only on the first answer.

* fix(mobile): show a PR AI launch notice only under the button that launched it

Fix checks and Resolve conflicts shared one error, warning and undelivered prompt, so a Fix checks
launch whose prompt was not sent also offered "Copy prompt" under Resolve conflicts, copying the
fix-checks prompt. Notices are now kept per button. The host availability notice stays under each
disabled button, since it explains why that button cannot be tapped.

* fix(mobile): say the host status is being retried instead of asking to reopen it

The host status gate now re-reads a failed status in the background, so "Go back and reopen it"
asked the user for a step that is no longer needed. The review sheet hint uses the same words.
The mobile web shell keeps its own copy.

* refactor(mobile): run the host status gate on the shared status probe

The gate had its own copy of the status probe's retry loop (same delays, same cutover and backoff
split, same stop on an undecodable status). The probe now takes an optional callback for each
failed attempt, which the gate uses to settle closed on the first failure, and the duplicate loop
and its now-unused reader are removed. Existing probe callers are unchanged.

* test(mobile): repin the RPC recording corpus after merging main

Re-records every golden against the merge commit and drops the three goldens whose
scenarios this branch removed, which the merge had restored from main.

* test(mobile): re-record the RPC goldens on the merge with main

Conflicted goldens were seeded from main and re-recorded against the merged
tree; every value either side recorded survives except main's terminal.send in
the PR triage launch, which this branch removes. Drops three goldens main still
had for scenarios this branch deleted.

* feat(mobile): confirm an AI button's agent started, naming the workspace

Fix checks, Resolve conflicts and commit recovery now show "Agent started in
<workspace>" under the button once the host started the agent with its prompt,
so a tap is no longer silent. The workspace label comes from the Source Control
panel and falls back to the branch.

* test(mobile): repin the recording baseline to the success-confirmation commit (header-only)

* fix(mobile): name the workspace in the diff review's AI-button confirmation

The diff review screen mounted the PR sidebar without a workspace label, so
"Agent started in ..." under Fix checks and Resolve conflicts named the branch
while the screen header named the workspace. The sidebar now requires the label
so no screen can drop it, and the diff review passes the one its header shows.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit, the last commit to touch a fenced
path. Against this branch before the merge, only header fields move:
baseline on every golden, and adapterSha256 on the 14 review-action goldens
whose adapter main now drives through the review sheet state. No recorded
body changed.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit, the last commit to touch a fenced
path. Against this branch before the merge only the baseline header moves,
on every golden; no recorded body changed.

* test(mobile): give the send-sheet stacking test the review controller's host status inputs

The merge with main brought in #22951's stacking test, which builds the review
controller without the host capability and status inputs this branch made
required, so the mobile test typecheck ratchet failed.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit 03995ae29d, the last commit to touch a
fenced path. Against this branch before the merge only headers move: baseline
on every golden, and adapterSha256 on the 14 goldens recorded through the
terminal adapter main changed in #23080. No recorded body changed, and the
merged corpus differs from main exactly as this branch did before.
2026-09-28 14:28:09 -07:00
Jinwoo Hong 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
2026-09-22 19:41:38 -04:00
Jinwoo Hong 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
2026-09-21 07:26:18 -04:00
Jinwoo Hong 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
2026-09-21 05:32:24 -04:00
Jinwoo HongandClaude ee61e3bd41 fix(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2) (#21735)
* feat(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2)

react-native-web's `Keyboard` is a stub: `addListener` returns a
subscription that never fires and `isVisible()` is always false. A screen
inside the shell's page that waits for `keyboardDidShow` waits for the
life of the document, and the software keyboard covers whatever sits at
the bottom of it. Two C4 screens are text entry at the bottom.

`platform/keyboard-occlusion` is the pair. The native file carries the
source-control hook's logic unchanged, events and clamp and the comment
that travels with it. The web sibling reads `visualViewport`: the layout
viewport keeps its size and the visual one shrinks, so the occluded strip
is `innerHeight - (height + offsetTop)`. `offsetTop` is in it because a
scrolled or pinched visual viewport sits partway down the layout viewport
and the strip below it is not keyboard; dropping the term reds two cases.
It listens on `resize` and `scroll` — the browser scrolling a focused
input into view moves the offset without resizing anything — and reads
once at mount, because a composer opened over an already-raised keyboard
receives no event at all; dropping that read reds a third case.

`useKeyboardAvoidingPadding` is a second name rather than a `Platform.OS`
branch at the call site. Natively it is 0 and subscribes to nothing, so a
composer that asks for it renders exactly as often as it does today;
`KeyboardAvoidingView` has already moved it and padding would move it
twice. On the web it is the whole of the avoidance, that view being
driven by the events this file exists because the page never receives.

No `visualViewport` answers 0 rather than guessing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): lift the commit bar and the note composer inside the page (OTA phase C, C4.2)

The two consumers move onto the seam. The hub's hook becomes one line and
keeps its name, which is what the hub's state calls the number. The note
composer takes the padding as a style on the `KeyboardAvoidingView` it
already had: natively that is 0, so the prop is `undefined` and the phone
renders exactly what it rendered before; inside the page it is the strip
the keyboard covers, which is the only thing that moves the composer
there.

The census is over both future route closures rather than over the two
call sites: `platform/keyboard-occlusion` is the one module in either
closure allowed to name the stub. Red first at the base commit — run in a
throwaway worktree at `9309350864` rather than by setting the fix aside —
it named `use-mobile-source-control-keyboard-lift.ts` as a subscriber
outside the seam and found the seam's web file in neither closure.

`mounted-bottom-drawer.tsx` is exempt by name, and the census asserts the
exemption is really in both closures so it cannot outlive its subject. It
reads more than a height — `Keyboard.metrics()` for a sheet opened over a
raised keyboard, and each event's `duration` to animate with it — which
the seam does not model, and it sits in C1's, C2's, C3's and C5's closures
too, so moving it is a change to every page rather than to this domain.
Its listeners are inert on the web the same way, which is why the composer
inside it takes its own padding rather than inheriting one.

No render-check case: measured, none of the five registered routes reaches
the seam, the commit bar or the composer, and a headless browser cannot
shrink the visual viewport independently of the layout one anyway. C4.4
carries it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the keyboard harness instead of asserting its fields (OTA phase C, C4.2)

The changed-code gate flagged the two `as` casts in the hoisted harness.
A return type on the `vi.hoisted` callback says the same thing and is
checked rather than asserted, which is the shape the host-list route test
already uses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read a pinch zoom as no keyboard, and test the clamp (OTA phase C, C4.2 round 1)

Round-1 folds plus CodeRabbit's exemption point.

**A pinch zoom read as a keyboard.** A 2x zoom shrinks the visual viewport
by exactly as much as a half-screen keyboard, so the commit bar and the
composer moved on a page nobody was typing into. A `scale` other than 1
answers 0. Geometry alone cannot tell the two apart and a stored "no
keyboard" baseline would be a heuristic, so a keyboard raised while zoomed
is the accepted rare case rather than a guess. `scale` is read defensively
because older WebViews do not implement it, and taking its absence for
zoomed would answer 0 for every keyboard on them; mutating the guard to
key on absence reds both cases.

**The clamp had no test.** A bare subtraction left all nine cases green.
The case is a visual viewport taller than the layout one, which mobile
Safari reports mid-scroll and which would have pushed the commit bar down
the screen instead of up.

**One guard, where the test reaches it.** `occlusion`'s `viewport ===
undefined` arm was unreachable: the effect returns before calling it, and
the absence case exercised that one. Deleted, and the remaining case says
which guard it proves.

**The census exempts two files, not a directory.** `startsWith('src/platform/')`
would wave through a later `src/platform/*.web.ts` that subscribed to the
stub directly, which is the defect this census exists for. Named exactly,
with a planted subscriber beside the seam as the fixture; restoring the
directory filter reds it.

**And the moved comment claimed an inset it never subtracted.** Deleted.
Correcting a comment that was false where it came from is not a rewrite of
the logic the move carried: no statement moved with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the page at scale 1 so the zoom guard is not the keyboard path (OTA phase C, C4.2 round 2)

Round 2's finding changes what the zoom guard costs. iOS auto-zooms on
focus of any input under 16px; both consumers' inputs are 14px
(`typography.bodySize`), and the page's viewport meta set no
`maximum-scale`. So `scale !== 1` was not the rare pinch the guard was
written for, it was every focus — and the seam would have answered 0 on
the one flow it exists for.

The guard stays and the premise is fixed instead: `maximum-scale=1` in
both places the page's meta is written, the built document in
`build-mobile-web-app-bundle.mjs` and the bootstrap `index.html`. iOS
honours it for the focus auto-zoom and has ignored `user-scalable=no`
since 10, so a deliberate pinch still works; the input sizes are
untouched. C4.6 step i is what settles it on a device.

Three test changes and one correction.

The census took a `rootDir`, as `findWebSiblings` does: it planted
`src/platform/other.web.ts` in the real tree while the overrides census
walks `mobile/src` in a parallel worker and would read it as an unlisted
override. It plants under `mkdtemp` now, and writes the two seam files
there too, so the empty result for them is the name exemption working
rather than those files happening not to subscribe.

A case for the ruling itself: scale 2 with a viewport shrunk past what
the zoom explains answers 0. Dropping the guard reds it and the pinch
case together.

`useKeyboardAvoidingPadding` is rendered through the test renderer now
instead of called outside one, with a counter on `Keyboard.addListener`.
Making the native hook return `useKeyboardOcclusion()` reds it at two
calls; the old shape could not see that, because a hook read outside a
component never runs its effects.

Item 4 did not hold as written. `window.visualViewport ?? undefined` is
not a no-op: the DOM declares the property `VisualViewport | null` and an
older WebView omits it entirely, so the coalesce was normalising both
shapes into one `=== undefined` check. Removing it and testing only for
`null` throws on the absent-viewport case (reproduced: `Cannot read
properties of undefined (reading 'scale')`). The coalesce is gone and the
guard names both shapes instead.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): raise the two page inputs to 16px on web instead of pinning the page scale (OTA phase C, C4.2 round 2)

`maximum-scale=1` is reverted from both metas. It fixed the right problem
in the wrong place: Android WebView honours it and iOS ignores it for
pinch, so the cost of stopping an iOS focus auto-zoom was deliberate
zoom on Android, taken from the users who need it most.

The font size is where it belongs. `src/platform/text-input-font-size.ts`
is the app's body size and `.web.ts` is that raised to 16, the size below
which iOS zooms on focus and does not zoom back. The commit bar and the
review note composer take their `fontSize` from it. A phone renders what
it rendered before: the native constant is `typography.bodySize`, so both
style objects are unchanged there.

`Math.max` rather than the literal, so a theme that raises the body size
past 16 keeps its own value.

The zoom guard stays and its rationale is rewritten to say what now keeps
the ordinary path off it: the inputs clear the floor, so a scale other
than 1 means a user pinched rather than an input took focus.

The pin is a unit case because the render check has no route to open yet.
Three assertions and what reds each: the web constant below 16 reds the
first, and a style going back to `typography.bodySize` reds the third,
which reads the two stylesheets as source because a node test resolves
the native sibling and would otherwise pass while shipping 14px to the
web. The overrides census covers the swap itself.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): put every text input in the two closures on the size seam (OTA phase C, C4.2 round 2)

The 16px floor reached two inputs and the rationale claimed a page. Eight
more text inputs in the same two closures still declared 14px, so a focus
on any of them zoomed the document and the occlusion seam — which reads a
scale other than 1 as no keyboard — stopped lifting for the rest of that
session. "A scale other than 1 means a pinch" was false while they were
there.

All eight go through `TEXT_INPUT_FONT_SIZE`, named by the census before
the change:

  src/components/MobileSearchField.tsx:175
  src/components/SmartWorkspaceAdvancedFields.tsx:84
  src/components/SmartWorkspaceSourceField.tsx:137
  src/components/new-worktree-form-styles.ts:125
  src/components/pr-sidebar/MobileLinkPrForm.tsx:120
  src/components/pr-sidebar/mobile-pr-sidebar-styles.ts:299
  src/components/pr-sidebar/pr-comment-composer-styles.ts:20
  src/components/smart-workspace-source-drawer-styles.ts:60

Every one declared `typography.bodySize`, so there was no input carrying
a size of its own to preserve and the phone is byte-identical again. Each
of those style keys was checked for consumers first: all of them are read
by a `TextInput` and nothing else, so raising the web value moves no
other element.

The census is the rule rather than the list. Over both closures it
resolves each `TextInput`'s style to the module that really declares the
size — following a spread, because both seam-served inputs are reached
through `{ ...base, ...list }` and a walk that stopped at the first
module would have called their offence absent — and names anything not on
the seam as `path:line`. A style with no `fontSize` inherits and is not
an offender. Presence precondition: the seam's web file is in the
closure, so an empty list cannot mean a page with no inputs.

Run against the previous head it prints exactly those eight for both
routes; three fixtures under mkdtemp cover the cross-module line, the
spread, and the two non-offender shapes.

The web test's rationale named `maximum-scale=1`, which is gone; it names
the input floor now.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): make the input census prove its own enumeration (OTA phase C, C4.2 round 2 addendum)

The offender list only says every text input is on the seam if every text
input was read, and the walk could not tell "this key sets no size" from
"I could not follow this style" — both answered nothing, so a resolution
failure would have read as a clean input and the rule would have gone
quietly vacuous.

`resolveStyleKey` answers three ways now: not found, found with no size,
found with one. `unresolvedTextInputStyles` reports the first as
`path:line (key)`, and the census asserts it is empty for both closures
beside asserting the offender list is.

Measured rather than assumed, which is what the addendum asks for. The
two closures hold 12 `TextInput` elements and 13 style references; none
uses an inline style object and none is without a style prop. All 13
resolve, 12 to `TEXT_INPUT_FONT_SIZE` and one — `styles.disabled`,
combined with `styles.input` on the same input — to a style that really
sets no size. The reviewer picker is in that list at
`mobile-pr-sidebar-styles.ts:300`; it was already on the seam from the
previous commit, which enumerated from the closure rather than from the
review.

A fourth fixture plants both shapes side by side: a style with no size,
which is not an offender, and a style reached through a package import,
which is named.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): close three holes in the text-input census (OTA phase C, C4.2 fold 3)

All three of CodeRabbit's findings are on the completeness property the
addendum bought, and all three reproduced before the change: each shape
below answered 0 offenders and 0 unresolved, which is to say it vanished.

Inline style literals. The walk recorded only `object.key` references, so
`style={{ fontSize: 14 }}` was neither an offender nor a hole. Style
props are flattened structurally now — arrays, spreads, `?:`, `&&` and
parentheses down to the expressions that can really land — rather than
walked as a subtree, which had the second bug of descending into an
inline literal's own properties. `&&` is followed because
`[styles.input, disabled && styles.disabled]` is the shape this tree
actually uses; `null`, `undefined` and `false` branches contribute no
style and are dropped rather than called unfollowable. An inline literal
resolves in place, and any other shape — a call, a bare identifier —
lands in the unresolved list.

Source-order precedence. `{ input: safe, ...legacy }` is `legacy.input`
at runtime, and answering direct keys before spreads read `safe` and
called the override clean. Properties are walked in reverse source order
now, direct keys and spreads in one pass, first answer wins.

The seam by binding. `size.text !== SEAM_EXPORT` accepted anything
spelled `TEXT_INPUT_FONT_SIZE`, so a local `const TEXT_INPUT_FONT_SIZE =
14` two lines up passed, and so did an import of that name from any other
module — the regression the seam exists to stop, wearing its name. The
identifier is resolved in the declaring module and accepted only as an
import from `src/platform/text-input-font-size`.

That last one changes what a fixture must say: the existing seam case
spelled the name without importing it, so it plants the seam module and
imports from it now. Six new fixtures, all six red on the previous walk.

Re-measured at this head, both closures: 12 `TextInput` elements, 13
style references, 12 on the seam, 1 sizeless (`styles.disabled`, combined
with `styles.input` on one element), 0 offenders, 0 unresolved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-20 01:29:30 -04:00
Jinwoo HongandClaude 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>
2026-09-19 21:06:05 -04:00
Jinwoo HongandClaude 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>
2026-09-19 20:51:47 -04:00
Jinwoo Hong 1cd2964501 perf(mobile): build the two projected git enums once, not per parse (#21311)
`readProjectedConflictOperation` and `readProjectedCompareStatus` constructed
a `z.enum` on every call, so every `git.status` and `git.branchCompare` reply
paid the constructor. Hoisted to module constants; the git-status payload
schema reuses the same instance. Behaviour is unchanged: same arms, same
fallbacks, identical reader output on all eleven recorded matrix cases.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 20:35:04 -04:00
Jinwoo Hong 40b2230508 test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* fix(mobile): move the last six reply-enum pins where tsc looks

mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.

Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:

- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
  arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy

openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.

Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): fix the type errors in eighteen test files

Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:

- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
  where act wants void, so each becomes a block. The async ones await only a
  genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
  member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
  the branch-compare summary's errorMessage, and SessionOptionDescriptor's
  transport, which #20884 added precisely so a producer could not inherit the
  wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
  refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
  becomes `advance?.kind`, one widened status arm takes `as const`, and the
  Expo notification fixture keeps `data` required because the dismissal cases
  assign through it.

631 test files pass, 6222 tests, unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): typecheck the test files, on a ratchet

mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.

tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.

The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.

Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile-recorder): correct the corpus counts and the salvage claim

The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.

"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the tests-typecheck ratchet's parser

The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.

Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): await the timer advances the act() rewrite dropped

Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): unshadow MobileHostCard's .tsx suite

A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census every test file into the typecheck program

The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(shared): make the enum helpers refuse the ways they can prove nothing

openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): describe the census and correct the baseline count

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the push fixture cast its SAFETY rationale

Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): build the push fixtures as typed notifications

Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the grouped-question advance read non-optional

`advance?.kind` let an absent advance take the null-draft branch instead
of failing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run the tests-typecheck ratchet on Windows

Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once

tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.

`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 18:58:25 -04:00
Jinwoo Hong 49274394fc refactor(mobile): put the branch-compare leg on the lifecycle owner, with a currency probe (step 5) (#21299)
* refactor(mobile): put the branch-compare leg on the lifecycle owner (step 5)

The compare kept three hand-rolled guards for one reply, combined in an
`isCurrentLoad()` the four exit points each had to remember to call:
`branchCompareGenerationRef` (latest-wins), `currentBranchCompareIdentityRef`
(the route identity, written in render) and `mountedRef`.

The owner replaces the first two. An attempt now `reset()`s and then `load`s, so
the newest attempt is the only one holding a live lease, and the reply is
published only through `commit(lease, value)`. What retires a compare is named
at the call site: this host, this route identity, this workspace.

A compare is a refresh, so neither of the owner's other two mechanisms applies
here and the `reset()` before each `load` is what says so: nothing it holds is
reusable, and no attempt may share its predecessor's reply. Dropping that line
makes the second attempt join the first's request and publish a base ref the
user already navigated away from.

The identity retire moves into the render-phase adjust-on-prop-change block,
where the identity ref was written. Leaving it to the next load's scope is not
the same thing: that load only starts once the fresh `git.status` returns, and
an in-flight compare would publish the old worktree's commits first.

`mountedRef` stays. A detached route has no screen to publish to, which is a
fact about the view, not about which reply is current.

The three decision points that used to write state mid-flight — no base ref, a
refused capability, an unreadable reply — are a returned `BranchCompareOutcome`
now, so the loader body writes nothing and the screen is written in one place.
That also puts this file under the loader-write source fence.

No golden moves: the recording suites reproduce byte for byte.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct the compare scope comment to the one call that reads it

The pilot's wording named two scope consumers; the compare leg has only `load`.
What the scope still adds over the render-phase retire is the structural half: a
scope the owner has not seen retires on its own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the lifecycle owner's loader a currency probe

A loader that spans two round trips had no way to ask whether its scope
had moved, so a superseded attempt sent its second request and was only
refused at commit. The probe answers exactly the question commit asks and
carries nothing to publish with, so the owner's publish fence is unchanged:
a loader that stops on it returns null, which the owner already reads as
no value.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a superseded branch compare off the wire

Restores request-count parity with main for the one path the migration
changed: an attempt superseded while it resolved its base ref used to stop
before sending git.branchCompare, and under the owner it sent one and was
refused at commit. It now stops on the owner's currency probe between the
two legs, so the screen is unchanged and so is the request count.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the probe's missing generation actually is

Stripping the directive gives TS2339, a member that does not exist, not a
privacy error: the probe has no generation to keep private.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a detached route sends no compare

The detach reset() was the only thing retiring an attempt after the route
went away, and deleting it left the suite green. This schedule detaches
mid base-ref lookup and asserts nothing reaches git.branchCompare; without
the reset() it fails with one request sent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the scope member the identity key already carries

statusIdentityKey is `${hostId}\0${worktreeId}`, so listing worktreeId
beside it read as a third fence when it fences nothing new.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): split the compare protocol out of the loaders hook

The outcome union, the attempt and the screen mapping are the compare
leg's own protocol, not the hook's: nothing in them reaches React. Moved
verbatim to mobile-branch-compare-outcome.ts with a unit pin for the
mapping, which only the hook's schedules covered before. The hook drops
from 283 to 230 lines against a 300 limit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): correct the joiner comment and narrow the compare sender

A joiner never receives the probe: its fn is never invoked, it awaits the
originating request's promise, and retire() clears inFlight so none can
join across a generation bump. The compare attempt takes the operation
sender the convention names rather than a whole RpcClient, which it only
ever used as that.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 18:39:18 -04:00
Jinwoo Hong 9add08bb59 test(mobile): recorder follow-ups — write ordinal, teardown streams, context anchor, salvage observation, provider pass-through, React draw (#21088)
* refactor(mobile-recorder): one shared write ordinal for requests, payloads and effects

`sent` stamped each payload and effect with the number of requests sent at
write time, which orders those two lists against sends but never against each
other. A family that sends no requests therefore had every stamp at `0`:
moving `host-worktree-refresh.ts`'s two initial snapshot reads from after
`client.subscribe` to before it moved none of the 705 goldens.

One monotonic counter per recording now stamps requests, payloads and effects
alike at the moment each is written, so the three append-only lists are ordered
against each other. The same reorder now fails five goldens. A request is
stamped at the logical `sendRequest` call rather than when its physical payload
is published, so a send that waited for connected carries two distinct stamps.

Full re-record from the pinned baseline: 699 bodies moved, 6 header-only,
0 added, 0 deleted; the only moved JSON paths are `sent` leaving and `ordinal`
arriving on `sender`, `payloads` and `effects`. Decoding with those two fields
stripped leaves all 705 header-only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile-recorder): observe streams still registered at teardown

Closing a stream only writes to the wire when its method has an unsubscribe
builder. `notifications.subscribe` has none, so a cleanup that forgets its
local `unsubscribeStream()` leaks a live registry record and nothing on the
wire changes. Until now that class was covered by one hand-written scenario
per method, which stops the stream and cuts over so the leak reappears as a
second subscribe payload.

Teardown now asks each session's `RpcClientStreamRegistry` what it still holds,
after the product's cleanup and before the transport disposes it, and records a
non-empty answer as a `streams-registered-at-teardown` effect carrying each
stream's method, subscribe payload and cancelled flag. The set is read off the
registry's own map: a mirror kept by the recorder would reproduce the product's
bookkeeping rather than observe it. Deleting `unsubscribeStream()` from
`mobile-notifications.ts` fails 7 goldens now, against 1 before.

Re-record: 4 bodies moved, 701 header-only, 0 added, 0 deleted. All four are
the two `runtime.clientEvents.subscribe` matrices, on partitions whose subscribe
reply is not a well-formed `ready`: with no subscription id to unsubscribe with,
the registry deliberately holds the cancelled record, which is why the
observation carries `cancelled`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-recorder): one host-client context exposure, anchored on the product source

Five adapter modules each carried `exports.recorderHostClientContext = Ctx;`
inside a source string appended to `client-context.tsx`. `Ctx` is a
module-private local, so the reference lives in a string no type checker
follows: renaming it typechecks clean and fails a recording with a
`ReferenceError` a hundred seconds in, five times over.

`hostClientContextExposure` and `loadHostClientContext` are the one copy, and
`adapter-seam.test.ts` asserts the declaration the exposure names still exists
exactly once in `client-context.tsx` and refuses a sixth inline copy. A rename
remains invisible to `tsc` — nothing but editing the fenced product module
makes a private local checkable — so the anchor is what turns it into one
failure that says what moved.

Also splits the subscription tests out of `recording-runner.test.ts`, which
items 1 and 2 had pushed past `max-lines`.

Re-record: 705 header-only, 0 bodies moved, 0 added, 0 deleted; `recorderSha256`
on all 705 and `adapterSha256` on the 23 goldens mounted through the five
modules.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile-recorder): record what a checked read salvaged

`collectSalvageDrops` builds a report on every decoded reply — which array
elements a `salvagingArray` threw away, which members a `salvagedOptional`
read as absent — and `classifyRpcReply` puts it on the outcome, where nothing
reads it. Which rows a reply lost was therefore visible nowhere, including in
a golden.

The recorder wraps `classifyRpcReply` on the mounted module, the one seam every
checked read passes through and the only one that knows the operation the drop
happened under, and records a non-empty report as a `reply-salvage` effect. No
product code changes; the report was already being built and discarded.

No golden carries one. All 19,384 checked reads in the corpus decode their reply
whole, because the reply matrix varies the envelope a host sends rather than the
shape of a row inside a result. The observation pins that absence, and moves the
first time a narrowed element or member schema drops a recorded row — including
where nothing downstream reads it. `salvage-observation.test.ts` is what keeps
the observation honest, driving a malformed row and a malformed optional through
the real `git.status` reply schema.

Re-record: 705 header-only on `recorderSha256`, 0 bodies moved, 0 added,
0 deleted.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(source-control): let hostedReview.create carry a provider token this build does not list

`HostedReviewCreate.provider` was a closed `z.enum`, so a client repeating back
a provider a newer host named in its own eligibility reply had its create
rejected at params validation. Mobile worked around it with a SAFETY-annotated
assertion: narrowing to `'unsupported'` before sending would have made the host
refuse its own provider, so the token was cast through instead.

The schema member is now `z.string()`, and both create handlers narrow through
`supportsHostedReviewCreation` before calling the runtime, so an arm this build
does not know answers `unsupported_provider` with readable copy rather than a
params error the client cannot act on. `createHostedReview`'s own refusal is
the single source of that copy. The mobile assertion is deleted.

Product change on a fenced path, so the goldens are not re-recorded: the whole
recording suite replays green against the corpus committed in the previous
commit, 825 passed, zero golden movement.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(source-control): annotate the runtime stub cast in the provider refusal test

The changed-code quality gate counts a new `as unknown as OrcaRuntimeService`
as a finding. A narrower stand-in does not exist: the interface has 1047
members and `Pick` of the three this test uses is not assignable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-recorder): pay React's lazy Math.random draw before the seeded run

React resolves `enqueueTask` by reading `module['require' + Math.random()]` and memoizes the
result, so a process draws exactly one `Math.random()` the first time it awaits `act`. The runner
drains through `act` after every step, so that draw landed inside whichever recording ran first and
ate the seeded sequence's first value: a family recording a `Math.random()`-derived param recorded
one value when it ran alone and a different one when it ran after any other family, and an adapter
could only dodge it by drawing in its factory ahead of the first drain.

The scheduler now pays that draw once per process, before it installs the seeded generator, so the
seeded sequence starts at the same value for every recording. Priming is awaited, which makes
`start` async.

Goldens re-recorded: 705 header-only, `recorderSha256` alone. No golden carried a first-in-process
value, so nothing moved in a body.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-recorder): drain before reading the streams left at teardown

The teardown observation read the registry after `dispose()` returned but before the scheduler
drained, so a cleanup that closes its stream on a due 0ms timer had not run yet and was recorded as
an uncancelled registration — the one shape this observation reserves for a cleanup that never ran.
A deferred close and a stream nobody ever closed were byte-identical.

The drain now runs before the read, with the transport still disposed after it. A second drain stays
after disposal: tearing the registries down rejects what the product still awaited, and an unhandled
rejection is an effect the cleanup checkpoint has to see.

Also: the registry size comparison in `registeredStreams()` could never fire, because `size()`
returns `this.streams.size` on the same object; `RECORDER_HOST_CLIENT_CONTEXT` is used only in its
own module and no longer exported; and `streamPayloads` now says what it holds, which is every frame
the registry publishes rather than only subscribes.

Goldens are stale in this commit and are re-recorded in the next one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the corpus after the baseline repin and the teardown drain

Recorded from a detached worktree pinned at 97aa5ff19b with this branch's recorder laid over it,
because two fenced product files still differ from the pin: the `hostedReview.create` provider
widening in `src/shared` and the mobile assertion it removes. `--record` in place refuses on that,
by design. A control run of the same harness with main's own recorder reproduced main's 705 goldens
byte-for-byte first, so anything below is attributable to this branch.

Against main, with `sent` and `ordinal` stripped: 701 header-only, 4 body moved, 0 added, 0 deleted.
The four are the two `runtime.clientEvents.subscribe` matrices already disclosed. Moving the drain
above the teardown read moved nothing: every non-empty set in the corpus is a cancelled record
waiting on a subscription id no drain can deliver.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): restore the type imports the recorder test split dropped

`subscription-recording.test.ts` annotated a mount with `RpcClient` without
importing it: vitest strips the annotation and mobile's tsconfig excludes
`**/*.test.ts`, so neither gate saw it. Typechecking the two moved suites under
a throwaway config that includes them also surfaced `sampleGolden` missing the
`adapterSha256` header the format has required since version 5.

The README's teardown claim is scoped to a due timer, since `flush()` only runs
work due at the current virtual time and a later timer is still registered at
the read.

Neither file feeds `recorderSha256`, so the corpus is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): scan the engine directory for a sixth exposure copy

The sixth-copy guard read only `adapters/`, so an inline copy appended to an
adapter failed and a new file under `adapters/` failed, but the same literal in
an engine file passed every assertion. Scan both directories, TypeScript
sources only, since the README quotes the string to document it.

`host-client-context-exposure.ts` holds the template with its interpolations
rather than the literal, so it still cannot match itself; a throwaway engine
file carrying the literal fails the test, and the file is otherwise green.

Also narrows the register's import statements before reading `moduleSpecifier`,
which drops a non-null assertion and the two TS2339 errors the `**/*.test.ts`
exclude was hiding.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 18:34:20 -04:00
Jinwoo Hong a28085adbf refactor(mobile): checked reply readers for the source-control domain (step 7 pilot) (#20950)
* test(mobile): ratchet the 201 unchecked RPC reply readers

Step 4 moved every call-site cast into an RpcOperation's `read`, but 201 of those
readers still answer `compatible: true` for any payload: `rpcUncheckedPayloadReader`
(163), `rpcReadUnchecked` (26 outside its own module) and `rpcUncheckedMemberReader`
(12), across 42 files. The cast moved; it did not become true.

Held as data with an AST boundary test, shaped on the raw-request-port ratchet: a file
that is not listed fails, a listed file that no longer has one fails, and a count that
rises fails. Only a call counts, so an import is not a reader and prose never is.

No behaviour change: this commit adds a list and a test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): validate the source-control domain's RPC replies at arrival

Replaces all 17 unchecked readers in mobile/src/source-control/ with
`rpcResultVariant(variant, schema)`, so a malformed reply is an
`RpcIncompatibleReplyError` naming the operation instead of a TypeError three
frames downstream. The inventory drops 201 -> 184 and the five source-control
operations files leave it entirely.

This is a behaviour change, scoped to malformed replies. Six reply-matrix
goldens move; every named-scenario golden and every `normal` partition is
byte-identical, which is the parity claim.

Schemas live one module per reply domain, beside the operations that read them:
git-status, git-compare, git-history, hosted-review and worktree-metadata. A
member is required only where a consumer reads it unguarded, and each schema
records the consumer line that justifies it. Nothing is `.strict()`; every
reply a consumer publishes verbatim keeps `z.looseObject` so an undeclared host
member still passes through. Six replies have no reader anywhere in mobile and
get `z.unknown()`, which is the honest schema for them, not a holdout.

Three readers stay total by construction, because their contract is that an
unreadable reply is a value rather than an error: the `git.status` projection
(a null status three screens route on), the `session.tabs.list` reveal (a null
list means poll again) and the generated commit message (a screen's copy, never
a decode error in a text field). They gain the salvage report, not a verdict.

Consumers take the schema's output type, so `MobileGitStatusResult` and the
branch-compare aliases now name what mobile reads rather than the desktop
aggregate, and seven call-site casts are gone.

Three requirements came from the goldens, not from the host types:
`git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility`
sends a `reviewLookupOutcome` the shared union does not list, and the
`git.status` projection writes an absent member as a present `undefined`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the six source-control reply-matrix goldens step 7 moves

Six goldens, all on malformed partitions. Every named-scenario golden and every
`normal` partition is unchanged, which is the parity claim for this step.

  git.history-read / git.history#1
    result-absent, result-null, inner-ok-missing, inner-false-string-error,
    inner-false-object-error: the load rejected with a TypeError reading 'items'
    or 'map' off undefined/null; it now rejects with
    `incompatible_reply: git.history-page (git.history)`.

  hostedReview.eligibility + create-intent / hostedReview.getCreationEligibility
    result-absent, result-null, inner-ok-*: the fetch fulfilled with the error
    envelope itself, re-typed as an eligibility and published into the compose
    prefill; it now rejects, and both callers already route that to the same
    "eligibility unavailable" state a null answer produced.

  hostedReview.create-chain + create-intent / hostedReview.create
    result-absent, result-null, inner-ok-missing, inner-false-object-error: the
    create form showed the raw TypeError text "Cannot read properties of
    undefined (reading 'ok')"; it now shows the incompatible-reply message.

Every header digest is unchanged -- baseline, recorder, adapter, scenario and
lockfile all match -- so the diff is the behaviour and nothing else.

Recorded from this branch into a scratch directory and copied in, because there
is no scoped honest alternative: scripts/rpc-recording.mts refuses to run unless
the product tree equals the pinned baseline, and the README's remedy for an
intended behaviour change is to repin, which rewrites the `baseline` header of
all 667 goldens. So these six now carry a pin whose tree no longer produces
them. That is a real gap in the oracle's design for behaviour changes, not a
detail of this step, and it needs a decision before this lands.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the four reply-schema properties the goldens found

Each of these cost a reply-matrix golden while writing the source-control
schemas, and none of them follows from reading the consumers or the host types:
a newer host's undeclared members must still decode, `git.history` sends
`timestamp: null`, `hostedReview.getCreationEligibility` sends a
`reviewLookupOutcome` the shared union does not list, and the `git.status`
projection writes an absent member as a present `undefined`.

The `.strict()` case is the one worth stating twice: at the top level it rejects
the reply, and on the entry it drops the row, which shows a dirty worktree an
empty Changes list. The fifth test pins the salvage report that makes such a
drop visible instead of silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give an unreadable reply a message a user can read

`RpcIncompatibleReplyError` put `incompatible_reply: <op> (<method>)` in
`message`, and `message` is what the screens hand to a toast. Step 7 is the
first change that can reach this error at all, so the token would have shipped
to users as its own error copy.

Fixed at the boundary rather than per site: `message` is now plain copy, and the
machine token moved to `code` (`incompatible_reply`) and `name`
(`RpcIncompatibleReplyError`), both readable by callers. The cross-bundle
fallback in `isRpcIncompatibleReplyError` matched on the old message prefix, so
it now matches on `name`, which a foreign copy of the module still carries.

No existing test pinned the old text. Two new ones pin the copy, the token and
the foreign-copy match.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to this branch and re-record

Commit adeb5f9531 recorded the six moved goldens into a scratch directory and
copied them back, which left them pinned to `e7206f62`, a tree that no longer
produces them. That is the one claim the `baseline` header exists to make, so
this replaces it with the README's remedy done in full.

`baseline` is now f741b2ea82, the last commit on
this branch that touches a fenced path, so the recording fence passes in place
and every golden is pinned to the tree that produced it. All 667 were
re-recorded through `scripts/rpc-recording.mts --record`; none were hand-edited.

Decoding every value pool against the branch point b8d4cde09f sorts the corpus
into 661 header-only moves where `baseline` is the only key that moved, 6 whose
body moved as well, 0 added and 0 deleted. The 6 are the disclosed step-7 delta,
unchanged at 69 moved observation fields across malformed reply partitions, plus
the readable incompatible-reply copy from f741b2ea82. No `normal` partition and
no named-scenario golden moved.

`scenarioSha256` hashes the derived scenarios, not the manifest, so the repin
moves no other header key; the README section this adds records that, the
scratch-copy failure mode, and the follow-up repin main needs after a squash
merge.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): narrow the incompatible-reply error by instanceof, not by cast

The two new tests in f741b2ea82 read the error through `as` casts, which the
changed-code casting gate rejects. An `instanceof` guard narrows the same value
and checks the class at the same time.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the branch tip and re-record

71d8c6a1e2 touched a fenced path (`mobile/src`), so the pin from 5f3f184fdf no
longer named the tree that produces these goldens. The fence compares the whole
of `mobile/src`, and a test file is inside it, so the pin follows the last commit
that touches a fenced path rather than the commit whose behaviour moved.

Re-recorded all 667 in place through `scripts/rpc-recording.mts --record`.
Decoding every value pool against the branch point b8d4cde09f still gives 661
header-only moves with `baseline` the only moved key, 6 body moves, 0 added and
0 deleted; the six and their 69 moved observation fields are unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record the four source-control reads that had no oracle

git.status (host payload), git.branchCompare, git.commitCompare and
git.branchDiff were migrated to checked readers with no recording observing
them, so a required member a host omits would have surfaced only in production.

Three families mount the owners rather than the senders, because each reply is
only visible in what the owner then publishes: the Changes screen's loader hook
(git.status, and the base-ref chain and git.branchCompare it triggers), the
history list screen (git.history and the per-commit git.commitCompare), and the
committed-diff opener hook (git.branchDiff). Ten goldens: three pilot recordings
and seven reply matrices.

Two adapter capabilities this needed. An inert FlatList never calls `renderItem`,
so the history adapter renders one row through the screen's own callback, both to
reach the handler that expands a commit and to read the file list back; without
that the commit-compare reply changes nothing observable. And `lowlight` joins
`react` and `zod` as a real library rather than a refusing proxy, because the
branch diff highlights on its success arm before the preview reaches state, so
the shipped text arm was otherwise unrecordable. No golden recorded its absence,
so only `recorderSha256` moves.

Recording the same scenarios against 4b0009d414, the pre-refactor tree, is the
before column. Decoding every value pool across the two gives 11 body moves and
666 header-only, 0 added, 0 deleted: the 6 already disclosed, plus the 5 new
matrices at 63 moved observation fields. What moved is the point. A malformed
git.status used to leave Changes `ready` over the malformed payload and go on to
fetch a branch compare; it now says the host sent a reply it could not read. An
absent git.branchDiff result used to put "Cannot read properties of undefined
(reading 'kind')" on the screen. An unreadable git.commitCompare used to spin the
expanded commit forever; it now says "No file changes".

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the merge commit and re-record

The merge is the last commit touching a fenced path, so it is the only tree
the recorder's fence can match. Every golden moves `baseline` and picks up
main's `recorderSha256` from #20920; the six the checked readers changed are
the only bodies that move against main.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): note the merge-commit pin and unwrap the recipe's record command

`format:check` from `mobile/` caught the wrapped inline command the recipe
had been carrying since it landed; pointing at the command above removes the
duplicate and the wrap together.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): open the source-control reply enums so a newer host's arm degrades

A closed `z.enum` in a reply schema is a version claim, and it refused replies
every declared reader could have rendered: a `git.branchCompare` summary status
of 'shallow-base' failed the whole Changes compare, a 'codeberg' provider failed
the whole eligibility, and a 'typechange' entry status dropped the row. Main
passed all three through.

`openEnum` in zod-salvage declares the arm set open: an unrecognised arm reads as
a member the consumers already handle, while absence and a non-string stay fatal.
Not `.catch()`, which would swallow those two as well.

`area` stays closed and says why: every arm grants stage, unstage or commit, so
there is no member to degrade to that would not offer an action against a row
this build cannot place. Main rendered such a row in no section either.

Also drops two claims the code does not back. Nothing reads the salvage report,
so the two comments promising a dropped entry "arrives as salvage.droppedPaths"
are gone, and `hostKind` on the non-text diff arm had no reader.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs: write down the open-enum rule and the header keys a branch moves

Rule 4 in the wire-compatibility page, beside the three rules it belongs with:
an enum arm set is a wire surface, unknown arms degrade rather than reject, and
leaving one closed is a decision to state where the schema is declared.

The recorder recipe's step 4 said `baseline` would be the only moved header key,
which is only true of a branch that never touched the recorder. It now names the
three digests a branch's own edits move, so a reader recognises a clean result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the recorder's own timeout killing a full re-record

The corpus records in ~110s warm and 160s under load, against a 120s budget, so
a full re-record was killed roughly half the time. A killed run wrote a partial
reporter banner and exited 1, which reads as a failing scenario rather than as a
run that never finished — it cost two investigations here. The budget is now ten
minutes, and a killed run says so.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the open-enum commit and re-record

`baseline` is the only header key that moves and no golden body moves: no matrix
partition scripts an unknown enum arm, so the corpus cannot see this change. The
eight schema unit tests are its only oracle.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop an unresolvable eligibility claiming the branch is not ready

Both fallback prefills set `canCreate: false`, which is a determination nobody
made. It short-circuits getMobilePrCreateBlockMessage before reviewLookupOutcome
is read, so a malformed, refused or rejected eligibility told the user "This
branch is not ready for a pull request yet." instead of asking them to retry.
Dropping it leaves `canCreate` undefined, which is what "unproven" means here.
Only a host that determined `canCreate: false` still gets the blocked copy.

`area` now degrades to absent rather than staying closed. Dropping the row also
dropped it from the unresolved-conflict gate, which grants create on a conflicted
worktree; absent withholds stage, unstage and commit while keeping the row, since
every area reader is an equality check. Its four consumers narrow explicitly: the
diff-review queue filters unplaceable rows, the opener withholds the route, and
the commit-failure prompt pins 'staged' where its own filter already did.

`git.branchCompare` entries are nullish, matching the `?? []` its consumers use.

Deletions: `MobileGitStatusProjection` and `uncheckedReaderCount` lose `export`,
the boundary test drops its dead inventory self-file (the AST counter finds zero
calls there, only prose), and `isRpcIncompatibleReplyError` is gone — it had no
caller in mobile, desktop or e2e.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): formatting and a thrown rejection in the round-2 tests

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the round-2 tip and re-record

The round-2 eligibility fix is a behaviour change, so the corpus has to be
re-recorded at a pin that includes it. Four goldens move body: the two
create-intent eligibility matrices on every non-normal partition, and the two
prefill scenarios that lose the fallback's `canCreate: false`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the main merge and re-record

The merge is now the last commit touching a fenced path, so the corpus has to
carry its sha. No body moves against the pre-merge corpus: main's engine change
shifts `recorderSha256` on every golden and nothing else, and main's fifteen
step-6 goldens re-record byte-identical.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): admit the three unchecked readers #20954 landed

The ratchet is a ceiling against this branch adding readers, not a claim about
what main may land. #20954 brought `notification-stream-closed`,
`native-chat-session-page` and `terminal-buffer-cleared`, so the merge has to
raise those lines and say where they came from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the inventory commit and re-record

The ratchet inventory is a fenced path, so admitting #20954's three readers
moved the fence head again. Baseline only; no body moves against the merge
re-record.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send the host's own provider token back instead of a fallback

`provider` is not a member mobile only reads. The eligibility reply names it and
the create call returns it, so `openEnum(..., 'unsupported')` did not soften a
reading — it rewrote the bytes, and a host that had just named `codeberg` refused
its own provider as unsupported. The action-sheet Create path has no provider
gate, so nothing caught it.

Passes the token through as a string from the reply to the create params. The
allow-list that decides whether mobile may create stays supportsHostedReviewCreation(),
which already answers no for a token this build does not know; its parameter
widens to `string`, since answering for an unknown token is the whole job. The
worktree-link switch gains a default, which also fixes an older hole: an
unrecognised provider used to fall out of the switch as `undefined` params.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the provider pass-through in the corpus

Repins to the provider fix and records `sc-create-intent-unlisted-provider`,
whose eligibility reply names `codeberg` and whose recorded `hostedReview.create`
params carry it back unchanged. Restoring the old enum fallback fails that
golden on `Request params mismatch: hostedReview.create#1` and nothing else.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 13:32:09 -04:00
Jinwoo Hong 44268d9616 refactor(mobile): send the github.* PR surface and the diff-review loaders through typed RpcOperations (#20668)
* test(mobile): record main's github.* PR and diff-review loaders before migrating them

Scenarios and goldens for the step-4 `src/session/` first half, recorded
against main's unmigrated product code so the migration that follows has a
frozen parity oracle instead of an assertion.

- 20 scenarios over seven new families: the seven `github.*` PR reads, the
  twelve PR mutations split by their three reply contracts (`{ok}` envelope,
  bare boolean, slug-addressed comment edit), the triage createTerminal+send
  launch, the PR branch-context chain and the review screen's three loaders.
- Two new sender-style mount adapters. Both mount exported async functions
  taking a client, so no React host is needed and the recorded state is each
  wrapper's own outcome.
- 50 new goldens: 20 pilot, 30 reply-matrix sites. `recorderSha256` moved on
  all 153 existing goldens because the adapters are in the whole-recorder
  digest; no other line in any of them changed.

Text diffs are deliberately unscripted: highlighting one reaches `lowlight`,
which the module loader refuses as an unspecified native dependency.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): send the github.* PR surface and the review loaders through RpcOperation

The step-4 first half for `src/session/`: eight files, 38 references to the raw
request port, all replaced with declared operations. No behaviour change — the
50 goldens recorded in the previous commit do not move, which is the claim.

- 21 operations over 21 methods. The seven PR reads keep their defensive
  parsers as readers; the ten status-envelope mutations share one reader
  because the `{ok, error}` convention is one host convention, not ten; the two
  bare-boolean mutations read the payload unchecked because `=== true` is the
  caller's confirmation rule.
- Four second readers, each justified in place: git.status and git.branchCompare
  for the PR branch context (a refusal costs a fallback, not the screen),
  git.branchCompare and git.branchDiff for review (the projection is not a
  superset of the verbatim payload), and worktree.show for the review notes the
  summary reader drops.
- Every failure text is preserved, including the two main kept apart: a refusal
  with no message falls back to the screen's copy, a transport drop with no
  message surfaces its empty message verbatim. `sendRaw`'s callers replaced
  theirs a second time, so those fall back on both paths.
- No retry, and no operation reads a dropped reply as a failed mutation: the
  rejection reaches each wrapper's catch as the original object.
- `github-pr-mutations.ts` split along the action/comment seam it already had
  in its consumers, so no file needs a max-lines bump.

Inventory: src/session/ 47 files / 114 references -> 39 / 76.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record the review snapshot answering its notes leg first

The barrier mutation census found one survivor: moving
`reviewWorktreeMetadataRead.interpret` inside the `Promise.all` in
`loadMobileDiffReviewSnapshot` changed nothing any golden observed. The base
scenario answers the branch-base legs before the notes leg, so by the time the
notes reply lands the compare leg has already sent `git.branchCompare` and the
two orders record the same sender list.

This scenario answers the notes leg first, while the compare leg is still
resolving its base ref, and checkpoints before the rest. At that checkpoint the
barrier is the whole difference: the correct order has nothing settled, the
early interpretation has already rejected the action. The mutation now fails it.

Recorded from a detached checkout of the previous commit, which carries main's
unmigrated product code with this branch's recorder over it, so the parity claim
stays non-circular. One new golden; no existing golden moved, because the family
base is unchanged and `scenarioSha256` is per golden.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): bind one git.status projection reader, not a copy per domain

The branch-context read declared its own `statusProjectionReader` with the same
parser, the same 'normalized-status' variant and the same empty salvage as
source-control's `gitStatusProjectionReader`, while its doc block claimed "one
reader serves both". Export the source-control reader and bind it here so the
claim is true; the doc now names the reader and keeps the part that is actually
different, which is what a refusal means on each policy.

No wire change and no golden moves: the reader is the same function value the
copy computed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): undo the github-pr-mutations split, which max-lines no longer forces

The split was made when the migrated file measured 319 lines. It does not any
more: `sendRaw`, `sendGithubPrMutation` and `extractMutationError` moved to
github-pr-mutation-outcome.ts and the prRepo/headSha allow-lists to
github-pr-repo-slug.ts, so the merged file is 293 lines against the 300 limit
and oxlint is clean.

Nothing imported github-pr-comment-mutations directly — every consumer went
through the re-export hub in github-pr-mutations — so the seam bought a reader
one extra file to open and nothing else. Merge it back and drop the hub.

Product-only: same wrappers, same params, same settle shapes, no golden moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one settleable-operation type for the PR reads and mutations

`GitHubPrMutationOperation` and the private `GitHubPrReadOperation` declared the
same two members for the same reason: a settle shape needs a bound operation's
method and its interpret, nothing else. Keep one, `GitHubPrSettleableOperation`,
and import it into the read settle. `extractMutationError` goes back to private,
as it was on main; it never had an importer outside its own file.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): drop the key-order claim from the PR param builder

The oracle does not observe param key order: `captureValue` in recording-values.ts
sorts keys, and no golden carries a raw frame string, so "the sender recordings
pin the bytes" was not a fact the evidence supports. The assertion stays for the
reason already in the doc — the builder is method-generic and returns a record.

`GitHubPrParamOptions` goes back to private; nothing outside the module names it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read the bare-boolean mutations with the shared unchecked reader

`mutationConfirmationReader` spelled out what `rpcUncheckedPayloadReader` already
returns, under the same 'pr-mutation-confirmation' variant that eleven other
operations in this tree get from the helper. Same function value, same variant,
so no golden moves. The comment explaining why the payload is left unread stays.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one RpcOperationSender for both domains, not one alias each

`MobileSessionRpcSender` and `MobileSourceControlRpcSender` were the same type
with the same doc, each derived from whichever operation its domain happened to
own. Replace both with `RpcOperationSender` in transport, derived from
`settingsRead` there, and name it for what it is: what a bound operation needs
to send with.

Still derived rather than restated, so no module names the raw request port to
accept a client; the port inventory and its ratchet are untouched.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): point the moved PR and diff-review adapters at the seam and register them

The merge commit carried the two adapter files into adapters/ with their old
specifiers and left the register untouched, so this completes the move: the
relative imports climb one more level, and both modules are registered in
adapters/mounted-operation-modules.ts as identifiers imported from their own
source, which is what adapter-seam.test.ts checks.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the session goldens against #20662's adapter seam

The merge brought #20568's per-golden scenario digest and #20662's per-golden
adapter digest, so the 51 goldens this PR owns move on four header fields and
nothing else: baseline, goldenFormatVersion, recorderSha256, and the newly
added adapterSha256. No recorded byte outside those headers changed.

baseline stays at main's own pin c6a7216984
rather than moving to 6a11a0b8e6. Repinning rewrites the baseline line in all
208 goldens main owns, which this branch must leave byte-identical. Recording
at either commit produces identical bytes everywhere except that one line, so
the pin costs no coverage.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 14:29:15 -04:00
Jinwoo Hong fc525c355d refactor(mobile): send the task workspace-creation domain through typed RpcOperations (#20568)
* test(mobile): record main's task workspace-creation RPC behaviour before migrating it

28 scenarios over nine task senders, recorded from main so the step-4 migration of
the workspace-creation half of src/tasks/ has a frozen answer to compare against.
Four senders mount as plain exported functions; three are model-chained hooks
mounted the way the settings adapters mount theirs.

The 153 existing goldens change header-only (`baseline`, `recorderSha256`): any new
scenario re-digests the recorder, and the pinned baseline had drifted from main
because the source-control migration landed. Content is byte-identical on all 153 —
verified field-by-field against HEAD.

`operation-module-loader.ts` now shares src/transport/rpc-delivery-ambiguity.ts with
mounted modules instead of evaluating a second copy. The mark is a WeakSet keyed on
the rejection object, so the copy the loader built had an empty registry and every
delivery-unknown rejection read as a definite failure inside the operation under
test — worktree.create's whole replay path was unreachable. With one registry,
`tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms, while the unstamped-create
scenario records the same rejection surfacing at 0 ms. No existing golden moves:
no other mounted module consumes the mark.

`task-preferences-optimistic` is re-anchored above the send rather than across it,
so migrating this file does not have to move the anchor. It still kills, and for
the same reason: the preset the screen shows no longer follows the tap.

Scenarios deliberately pin the empty-message refusals (`*-refused-empty-message`,
`*-empty-message`), because a refusal with no message falls back to the screen's
copy while a transport error with no message does not, and the two paths are easy
to collapse when a call site moves behind an acceptance policy.

Goldens: 153 -> 201, 2.9M -> 3.7M.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): format the recording manifest and re-digest the goldens

`oxfmt --check` from mobile/ collapses a one-element `sites` array in each new
scenario. The JSON value is unchanged — verified by comparing both files parsed
and key-sorted — but the manifest is inside `recorderSha256`, so all 201 goldens
carry a new digest. Every other field, header and observation alike, is
byte-identical.

Re-recorded in a separate worktree at the previous commit so the goldens stay
attributable to main's product source rather than to the migration that follows.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): separate the goldens from the migration, and re-digest

The previous commit accidentally carried the product migration alongside the
manifest format, which both broke the commit that is supposed to prove parity and
left the suite red: the digest was recorded without a comment move that a lint fix
had made inside the adapter, so all 201 goldens failed their `recorderSha256`
header.

This backs the product half straight out again — the next commit re-applies it
byte-for-byte — and re-records from the pinned baseline in a separate worktree
carrying this branch's recorder, per the procedure in the recording README. Every
field except `recorderSha256` is byte-identical to the previous commit's goldens on
all 201 files, so no observation moved in either direction. The suite is green here
with main's product source, which is what makes the next commit's "no golden
changed" claim mean something.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): send the task workspace-creation domain through typed RpcOperations

12 of src/tasks/'s 37 raw-port files now send through a declared operation instead of
the raw request port: 36 references to 0, leaving 25 files and 73 references for the
provider item/detail/mutation half. No golden moved — `git show --stat` on this commit
touches nothing under mobile/rpc-foundation/, which is the parity claim.

Twenty-two operations over twenty methods, in four modules named for what they send:
workspace create (create, PR/MR base resolution, create-time capabilities), workspace
source (SSH connect/state, agent detection, repo hooks, sparse presets, ref search),
task runtime (status, ui.get/ui.set, preflight, Linear status, settings.update) and the
Smart picker's provider reads.

Two methods carry two policies each, and both pairs are named. `status.get`: the Tasks
screen cannot hydrate without it and surfaces the host's message, while create-time
capability probing degrades to "no capabilities" and creates anyway — so one throws on
refusal and one skips. `ui.set`: two sites await it, one is fire-and-forget and never
interprets the reply at all. Both pairs share one reader, so no method has two readers.
No new acceptance policy.

worktree.create keeps its delivery-unknown contract. `request` returns the transport
promise itself, so the retry loop catches the object the transport marked; two new tests
assert `toBe(marked)` in one direction and that an unmarked rejection stays unmarked in
the other, because a mark added on the way out would replay a create the host never
received. `tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms.

Three sites still read the raw refusal envelope before interpreting, because the code or
the message decides the route and no acceptance policy carries either through: the create
retry needs the message for `isRetryableWorktreeCreateConflict`, and the paste lookup
needs `method_not_found` to retire the slug probe host-wide. Both are documented at the
site.

The hydration barrier keeps raw requests inside its `Promise.all`. main's group rejects as
soon as one leg rejects; `startRpcOperation` + `interpretAtRpcBarrier` would wait for the
slowest peer and let a later policy surface a different error. Interpretation stays after
the `stale` guard, where it was.

`WorkspaceCreateParams` is now `RpcSendParams<'worktree.create'>` rather than
`Record<string, unknown>`, which types the builder and the operation together; every field
the three builders already sent typechecks against the host schema unchanged.
`RpcSendArguments` now also makes params optional for a method whose params type has no
required field, because `preflight.check` is such a method and main sent it none —
requiring `{}` would have put a new object on the wire.

The Mobile Tasks source-parity hashes move for the same reason bound settings requests
moved them: the method string and the envelope read leave the screen. The signature diff
is evidence rather than a re-pin — `semantics` is a pure deletion of 22 `rpc:` call
signatures and 22 method literals with nothing added, statement/declaration/render/style
counts are unchanged, and render tokens, styles and declarations are byte-identical.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the task workspace adapters at the sender/hook seam

The single adapter file reached 344 lines against mobile's 300-line limit. CI lints
every file, so this is red there even though the changed-code gate does not report it.
Split along the seam the recording README already draws: exported async senders that
take a client and need no React host, and the drawer's three model-chained hooks.
No adapter body changed.

Both files are inside `recorderSha256`, so all 201 goldens carry a new digest. Every
other field is byte-identical, verified file by file. Re-recorded from the pinned
baseline in a separate worktree carrying this branch's recorder, so the goldens stay
attributable to main's product source.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the one-key unchecked reader a name

Four readers were the same three lines: read one property off the reply, wrap
it unchecked. `rpcUncheckedMemberReader` is the one-key sibling of the existing
`rpcUncheckedPayloadReader`, so the annotation and the closure go away at each
site. The pilot's `commitCompareEntriesReader` is converted too, so the helper
has no longhand twin left to copy from.

No behaviour change: the helper composes the same `rpcReadUnchecked` over
`rpcPayloadMember`, including the property-read exception on a null result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record the local arm of workspace agent detection

`preflight.detectAgents` was the one migrated operation with no recorded
coverage: the ssh adapter hardcoded `connectionId: 'ssh-1'`, so the detection
effect's ternary only ever took the remote arm and the local call site could be
repointed at another method without a golden noticing.

The adapter now takes the connectionId as a parameter and registers twice;
`tasks.workspace-ssh-local` mounts the same hook with no connection, which is
the only difference the effect branches on. Recorded at the pinned baseline
with this branch's recorder laid over it, so the new golden is main's
behaviour and the migrated code has to reproduce it — it does.

Goldens: two added (`tw-workspace-ssh-local-agents` and its reply matrix). The
other 201 changed on `recorderSha256` only, because the adapter edit moves the
recorder digest every golden pins.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drive workspace create and the Linear list to a recorded wire

Two operations passed a policy swap unnoticed, both because no golden reached
their acceptance branch.

`worktree.create`: the create hook's fixture resolved setup to a prompt, so all
three settings.task-workspace scenarios stopped before the request and the only
consumer that hands a refusal to interpret was never recorded. The adapter now
takes the setup resolution as a parameter and registers a second family that
resolves it, so createWorkspace runs to the wire. Two scenarios: a Linear item
that creates directly, and a GitHub pull request that resolves its base first,
which also puts this hook's built params — start point, generated display name,
agent launch fields — in a golden for the first time. The existing prompt
family is untouched, so its recordings still pin that branch.

`linear.listIssues`: it appeared only in a non-base scenario, and the matrix
reads the family base, so the family had no partition for it. The base now
lists assigned issues after searching.

Goldens: five added. Five moved beyond the digest, all derived from the
smart-search base that gained the list leg. The other 198 changed on
`recorderSha256` only. Recorded at the pinned baseline with this branch's
recorder laid over it, so every new golden is main's behaviour.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the unreachable unmount branches from the task adapters

Nothing dispatches `unmount` to these three adapters: the only producer is
`lifecycleSchedules`, driven from a hardcoded five-id list that names no
task-workspace family, and it pushes a `remount` right after, which these
adapters would throw on. The branch read as lifecycle coverage that was never
wired up. `dispose: hook.unmount` already tears the mount down.

Goldens re-recorded at the pinned baseline because the recorder digest moved;
`recorderSha256` is the only line that changed in all 208.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens at main's post-squash baseline

Recorded from a detached checkout of e53f1557e1 (main's unmigrated
product code) with this branch's recorder laid over it, so the parity
claim stays non-circular.

- `baseline` repinned to e53f1557e1 on all 208 goldens; main pinned
  5ec0b2698f, a pre-squash branch commit not reachable from main.
- `recorderSha256` moved on all 208 because this branch's adapters are
  in the whole-manifest digest.
- 55 task-workspace goldens re-recorded at the new baseline.
- No other line in any of main's 153 goldens changed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the goldens under #20562's per-scenario digest

Baseline repinned to 50e752fc66 and all 208 goldens recorded from that
commit's unmigrated product tree with this branch's recorder laid over it.
recorderSha256 moves on every golden because the task-workspace adapters
live in the recorder directory. scenarioSha256 does not move on any of
main's 153: the manifest only adds 31 scenarios and edits none, which is
the property #20562 was built to give.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-14 13:36:16 -04:00
Jinwoo Hong 7d98c8e2f3 refactor(mobile): send the source-control domain through typed RpcOperations (#20544)
* test(mobile): record main's source-control RPC behaviour before migrating it

45 scenarios over 11 source-control senders, recorded from main so the step-4
migration has a frozen answer to compare against. Adapters mount the real
exported senders as plain functions, so no React host or device is needed.

The 73 existing goldens change header-only (`baseline`, `recorderSha256`): any
new scenario re-digests the recorder, and the pinned baseline had drifted from
main in `src/shared` so recording required bumping it. Content is byte-identical
on all 73 — verified field-by-field against HEAD.

Scenarios deliberately pin the empty-message cases (`sc-*-refused-empty-message`,
`sc-*-rejected-empty-message`), because a refusal with no message falls back to
the screen's copy while a transport error with no message does not, and the two
paths are easy to collapse when a call site moves behind an acceptance policy.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): send the source-control domain through typed RpcOperations

13 of the domain's 14 files now send through a declared operation instead of the
raw request port: 44 references to 0. The holdout is use-mobile-git-requests.ts,
whose single reference is a `(method: string, params)` dispatcher that five other
hooks feed `{ method, params }` action steps at runtime; typing it is a step model
change, not a call-site move, so its line stays at 1.

Fifteen operations over fourteen methods. Two of them read git.status, and that is
deliberate: the Changes screen publishes the host payload verbatim while
hosted-review preparation reads the normalized projection, which returns null when
`entries` is not an array and drops entries missing a path. Sharing the projecting
reader would change what the Changes list renders, so both are named.

Four loads still read the refusal envelope before interpreting, through
readMobileGitRefusal: two degrade to a capability-missing screen, one retries a
selector that is not visible yet, and one falls back from files.openDiff to
files.open. `isMobileGitUnavailable` consults the code *and* the message and no
acceptance policy carries either through, so the alternative was parsing a code out
of a message. No new acceptance policy was added.

Every migrated site keeps two error paths where it had two: a refusal with no
message falls back to the screen's copy, a transport rejection surfaces its own
message verbatim and keeps its delivery-unknown mark. Collapsing them into one catch
is what would have turned an unknown mutation into a failed one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-digest the goldens after a lint fix in the new adapter

recorderSha256 only, all 118 files; every recorded observation is byte-identical.
Re-recorded from c57de48fd0 in a separate worktree so the goldens stay attributable
to pre-migration product source — recording from this branch would have made the
parity claim circular.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drive the reply matrix over every scripted reply, and fail closed

The matrix picked its driven request from a hardcoded prefix list and `continue`d
past any family the list did not name. That was 10 of 23 families — every one the
source-control migration added — with no red test to say so, which is why that
migration's mutation evidence came down to single hand-written scenarios.

`replyMatrixSites` now takes every completion step in a family's base scenario:
61 sites instead of 13, one golden per site, no judgement about which request is
the "real" one and nothing to edit when a domain is added. A family that scripts
no reply throws, a repeated request name throws, and a census test asserts every
family in the manifest has a matrix. A variant's downstream replies are marked
`optional` and answered only if the request is outstanding, so a diverged reply
that ends the chain records the truth instead of failing on an unsent request.

The `normal` partition replays the first fulfilled reply the family records for
that request, rather than a payload the test file invented per family. Absent and
null do not count — each is already a partition — so four sites with no other
recorded success are inventoried in REPLY_MATRIX_NORMAL_RESULT_INVENTORY with a
reason each, and an entry whose family later records a success fails.

Two partitions added: a refusal and a transport rejection with no message. That
is the axis that separates a refusal falling back to the screen's copy from a
transport drop surfacing its empty message verbatim; without it the two paths
produce the same text and collapsing them is invisible. Every source-control
family carried a hand-written `*-empty-message` scenario for exactly that.

13 hand-written scenarios the matrix now covers are deleted: 8 `*-empty-message`
cases plus sc-history-rejected, sc-commit-message-null-result, sc-eligibility-
refused, sc-create-stops-on-push-refusal and sc-base-ref-rejected. Kept, with
reasons, are the ones the matrix cannot reach: a different action or action args
(sc-review-commit-*, sc-prefill-*, sc-create-{refused,rejected}-empty-message,
sc-prerequisite-{publish,force-with-lease,skipped}), a payload shape rather than
an envelope shape (sc-review-status-entries-not-array, sc-create-existing-review),
and multi-request combinations (sc-base-ref-{unavailable,repo-fallback},
sc-reveal-timeout).

Goldens: 118 -> 153. All 92 survivors changed by their `recorderSha256` line only;
no recorded observation moved. Re-recorded from the pinned baseline in a separate
tree so the goldens stay attributable to pre-migration product source.

`matrix-hostedreview.create-intent-git.commit-1` fails on this branch, and it is
a true positive: `hostReplyErrorTextOrFallback` stringifies a non-string in-band
host error where main returned `result?.error || fallback` and passed the object
through. Left failing — the fix is a product change, documented in the README.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep main's in-band commit error pass-through

The expanded reply matrix caught a real divergence the nine original partitions
missed. Main returned `result?.error || 'Commit failed'`, passing a truthy
non-string straight through under a `string` annotation; the migrated helper
stringified it to "[object Object]".

Stringifying is arguably better — downstream does `result.error.replace(...)`,
which throws on an object and merely looks ugly on a string. But this migration's
contract is that no behaviour changes, and shipping an unannounced improvement
inside a refactor is exactly what the parity evidence exists to prevent. Restores
the pass-through; the latent throw is its own ticket.

No host sends this today (`git.commit` is typed `{success, error?: string}`), but
nothing validates it and mixed client/host versions are normal.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-digest the goldens for the merged recorder

Main inverted two guards in family-recordings.test.ts and pilot-recordings.test.ts.
No behaviour change, but both files are inside recorderSha256, so all 153 goldens
failed the header check after the merge.

Re-recorded from 16d1ab81d3 in a separate worktree carrying main's product source
and this branch's merged recorder, so the goldens still capture main's behaviour
rather than the migration's. `baseline` moves from 7ce8e18d07 to 16d1ab81d3 because
main touched src/shared/skills*.ts, which the record guard compares; that change
moved no recorded observation. Every field except `baseline` and `recorderSha256`
is byte-identical across all 153 files.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): intern each observation entry instead of the whole field

Making the reply matrix fail closed took the goldens from 118 files / 1.53 MB to
153 / 5.35 MB, because a per-site golden replays the chain across 11 reply
partitions and every checkpoint's sender, payloads, settlements and effects
re-state the whole history that came before them. Format version 2 pooled those
fields whole, so the shared prefix was stored once per checkpoint, and once per
partition again.

Version 3 pools each entry of a list or map field instead. `golden-value-pool.ts`
declares the container per field rather than sniffing it from the value, so a
projection that changes one fails loudly instead of silently switching encodings.
153 files / 5.35 MB becomes 153 / 2.78 MB; the family that drove this,
hostedReview.create-intent, 2.0 MB over 12 sites becomes 792 KB.

This is a re-encoding, not a re-observation. Every one of the 153 goldens resolves
to the recording its version 2 file resolved to, checked field by field, and every
header field except recorderSha256 and goldenFormatVersion is byte-identical. The
three mutations this branch's coverage rests on fail exactly as before: the
gitStatusProjectionRead acceptance policy 16 (13 matrix, 3 hand-written),
interpret inside the request chain 5 (all matrix), and the rewrapped transport
rejection 4 (all matrix).

It also makes diffs smaller, which is the opposite of what version 2's note
predicted when it rejected this. Adding a timeoutMs to the first git.status of the
create-intent chain touches the same 16 goldens either way, but version 2 moves
17,100 lines / 1.03 MB and version 3 moves 3,764 / 0.20 MB, because a changed
entry no longer rewrites every field value containing it.

`readGolden` now also refuses a pool entry that does not hash to its own key, and
one no checkpoint reads. Content addressing is what makes an entry shared between
checkpoints safe to share; an unreferenced entry would be content in the file that
nothing compares.

Recorded from 16d1ab81d3 with this branch's recorder laid over it, per the
README's flow. The record fence is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): make the matrix census and inventory checks able to fail

Three review findings, all in the recorder, none in product code.

The family census pushed every family unconditionally, so it could never differ
from the manifest keys; it now records a family only when a site generated a
test, which is independent of replyMatrixSites throwing on an empty list.
REPLY_MATRIX_NORMAL_RESULT_INVENTORY was only consulted for a live site, so a
stale entry retired silently; a new assertion fails on any entry that names no
live (family, request). Both verified by mutation: an empty site list and a
renamed inventory request each fail the suite. The value pool resolves hashes
with Object.hasOwn so a malformed golden cannot read an inherited key.

Re-recorded from 16d1ab81d3 with this recorder laid over main's product source,
per the README. All 153 goldens move on recorderSha256 only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-14 01:02:28 -04:00
Neil ef3b7e83b9 perf(mobile): reuse numeric collators across source control sorts (#20224) 2026-09-12 18:15:22 -07:00
c300913f90 fix(mobile): stop double-scaling commit timestamps in history rows (#17731)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-09-06 23:27:13 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00